Compare commits

...

9 Commits

Author SHA1 Message Date
Yaojia Wang
8c51fa392b Refactoring
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
2025-11-23 23:40:10 +01:00
Yaojia Wang
0951c53827 fix(backend): Fix ApiKeyId lookup in PendingChangeService
The PendingChangeService was looking for 'ApiKeyId' in HttpContext.Items,
but McpApiKeyAuthenticationHandler sets 'McpApiKeyId'. Updated the lookup
to check both keys for backward compatibility.

Changes:
- Modified ApiKeyId retrieval to check 'McpApiKeyId' first, then fall back to 'ApiKeyId'
- Prevents McpUnauthorizedException: API Key not found in request context

Fixes compatibility between McpApiKeyAuthenticationHandler and PendingChangeService.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:39:13 +01:00
Yaojia Wang
9f774b56b0 feat(backend): Add CreateProjectSdkTool for MCP SDK
Adds a new MCP SDK tool that allows AI to create projects in ColaFlow.
The tool creates pending changes requiring human approval.

Features:
- Validates project name (max 100 chars)
- Validates project key (2-10 uppercase letters, unique)
- Validates description (max 500 chars)
- Checks for duplicate project keys
- Generates diff preview for human approval
- Retrieves owner ID from authentication context (JWT or API key)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:36:36 +01:00
Yaojia Wang
a55006b810 fix(backend): Use tenant_id claim name in MCP API Key authentication
Fixed TenantId claim name mismatch between McpApiKeyAuthenticationHandler
and ITenantContext implementations. Changed claim name from "TenantId" to
"tenant_id" to match what TenantContext.GetCurrentTenantId() expects.

This fixes the "TenantId cannot be empty" error when MCP SDK Resources
attempt to retrieve the tenant ID after API Key authentication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:32:04 +01:00
Yaojia Wang
b38a9d16fa feat(backend): Add API Key authentication to /mcp-sdk endpoint
This commit adds API Key authentication support for the Microsoft MCP SDK
endpoint at /mcp-sdk, ensuring secure access control.

Changes:
- Fix ApiKeyPermissions deserialization bug by making constructor public
- Create McpApiKeyAuthenticationHandler for ASP.NET Core authentication
- Add AddMcpApiKeyAuthentication extension method for scheme registration
- Configure RequireMcpApiKey authorization policy in Program.cs
- Apply authentication to /mcp-sdk endpoint with RequireAuthorization()

The authentication validates API keys from Authorization header (Bearer token),
sets user context (TenantId, UserId, Permissions), and returns 401 JSON-RPC
error on failure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 15:14:09 +01:00
Yaojia Wang
34a379750f Clean up
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
2025-11-15 08:58:48 +01:00
Yaojia Wang
4479c9ef91 docs(mcp): Complete Phase 3 Runtime Testing and Validation
Phase 3 runtime testing has been completed with critical findings:
- Microsoft MCP SDK is registered but NOT actually used at runtime
- Application uses custom HTTP-based MCP implementation instead of SDK's stdio
- SDK tools (Ping, GetServerTime, GetProjectInfo) discovered but not exposed
- Requires architecture decision: Remove SDK, Use SDK properly, or Hybrid approach

Test artifacts:
- Complete test report with detailed analysis
- Summary document for quick reference
- Runtime test scripts (PowerShell)
- API key creation utilities (SQL + PowerShell)

Key findings:
- Transport mismatch: SDK expects stdio, app uses HTTP
- Tool discovery works but not integrated with custom handler
- Cannot verify DI in SDK tools (tools never called)
- Claude Desktop integration blocked (requires stdio)

Next steps:
1. Make architecture decision (Remove/Use/Hybrid)
2. Either remove SDK or implement stdio transport
3. Bridge SDK tools to custom handler if keeping SDK

Test Status: Phase 3 Complete (Blocked on architecture decision)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 22:47:19 +01:00
Yaojia Wang
fda586907e feat(backend): Install and integrate Microsoft MCP SDK v0.4.0-preview.3 (Phase 1 PoC)
This commit implements Phase 1 of the MCP SDK migration plan:
installing the official Microsoft ModelContextProtocol SDK and
creating a Proof-of-Concept to validate SDK capabilities.

Changes:
- Installed ModelContextProtocol v0.4.0-preview.3 NuGet package
- Added SDK server configuration in Program.cs (parallel with custom MCP)
- Created SdkPocTools.cs with 3 attribute-based tools:
  * Ping() - Simple test tool
  * GetProjectInfo() - Tool with parameters
  * GetServerTime() - Tool with dependency injection
- Created SdkPocResources.cs with 2 attribute-based resources:
  * GetSdkStatus() - SDK integration status
  * GetHealthCheck() - Health check resource
- Enabled auto-discovery of Tools and Resources from assembly

SDK Key Findings:
-  Attribute-based registration works ([McpServerToolType], [McpServerTool])
-  [Description] attribute for tool/parameter descriptions
-  Dependency injection supported (ILogger<T> works)
-  Parameter marshalling works (Guid, bool, defaults)
-  Async Task<T> return types supported
- ⚠️ McpServerResource attribute ONLY works on methods, NOT properties
-  Compilation successful with .NET 9

Next Steps (Phase 2):
- Test SDK PoC at runtime (verify Tools/Resources are discoverable)
- Analyze SDK API for Resource URI patterns
- Compare SDK vs. custom implementation performance
- Create detailed migration plan

Related:
- Epic: docs/plans/sprint_5_story_0.md (MCP SDK Integration)
- Story: docs/plans/sprint_5_story_13.md (Phase 1 Foundation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 22:32:42 +01:00
Yaojia Wang
63ff1a9914 Clean up
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
2025-11-09 18:40:36 +01:00
136 changed files with 9996 additions and 1517 deletions

View File

@@ -1,25 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git commit -m \"$(cat <<''EOF''\nfix(frontend): Add comprehensive debug logging for Epic creation\n\nAdd detailed console logging to diagnose Epic creation issue where \nno request is being sent to backend.\n\nChanges:\n- Add form submission event logging in epic-form.tsx\n- Add API request/response logging in epicsApi.create\n- Add HTTP client interceptor logging for all requests/responses\n- Log authentication status, payload, and error details\n- Log form validation state and errors\n\nThis will help identify:\n- Whether form submit event fires\n- Whether validation passes\n- Whether API call is triggered\n- Whether authentication token exists\n- What errors occur (if any)\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit -m \"$(cat <<''EOF''\nfix(frontend): Fix Zustand authStore hydration timing issue\n\nFix race condition where Epic form checked user authentication before\nZustand persist middleware completed hydration from localStorage.\n\nRoot cause:\n- authStore uses persist middleware to restore from localStorage\n- Hydration is asynchronous\n- Epic form checked user state before hydration completed\n- Result: \"User not authenticated\" error on page refresh\n\nChanges:\n- Add isHydrated state to authStore interface\n- Add onRehydrateStorage callback to track hydration completion\n- Update epic-form to check isHydrated before checking user\n- Disable submit button until hydration completes\n- Show \"Loading...\" button text during hydration\n- Improve error messages for better UX\n- Add console logging to track hydration process\n\nTesting:\n- Page refresh should now wait for hydration\n- Epic form correctly identifies logged-in users\n- Submit button disabled until auth state ready\n- Clear user feedback during loading state\n\nFixes: Epic creation \"User not authenticated\" error on refresh\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git commit:*)",
"Bash(powershell.exe -File verify-user-fix.ps1)",
"Bash(powershell.exe -File verify-user-fix-simple.ps1)",
"Read(//c/Users/yaoji/git/ColaCoder/**)",
"Bash(powershell.exe:*)",
"Bash(timeout 30 bash -c \"while [ ! -f ''colaflow-web/components/tasks/task-list.tsx'' ]; do sleep 2; done; echo ''Components detected''\")",
"Bash(npx shadcn@latest add:*)",
"Bash(cat:*)",
"Bash(timeout 30 bash -c \"while [ ! -f ''colaflow-web/components/projects/acceptance-criteria-editor.tsx'' ]; do sleep 2; done; echo ''Components detected''\")",
"Bash(curl:*)",
"Bash(echo:*)",
"Bash(Select-Object -Last 50)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(dotnet build:*)",
"Bash(dotnet test:*)",
"Bash(git add:*)"
"Bash(taskkill:*)",
"Bash(powershell Stop-Process -Id 106752 -Force)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,105 @@
// C# Script to explore ModelContextProtocol SDK APIs
#r "nuget: ModelContextProtocol, 0.4.0-preview.3"
using System;
using System.Reflection;
using System.Linq;
// Load the ModelContextProtocol assembly
var mcpAssembly = Assembly.Load("ModelContextProtocol");
Console.WriteLine("=== ModelContextProtocol SDK API Exploration ===");
Console.WriteLine($"Assembly: {mcpAssembly.FullName}");
Console.WriteLine();
// Get all public types
var types = mcpAssembly.GetExportedTypes()
.OrderBy(t => t.Namespace)
.ThenBy(t => t.Name);
Console.WriteLine($"Total Public Types: {types.Count()}");
Console.WriteLine();
// Group by namespace
var namespaces = types.GroupBy(t => t.Namespace ?? "No Namespace");
foreach (var ns in namespaces)
{
Console.WriteLine($"\n### Namespace: {ns.Key}");
Console.WriteLine(new string('-', 60));
foreach (var type in ns)
{
var typeKind = type.IsInterface ? "interface" :
type.IsClass && type.IsAbstract ? "abstract class" :
type.IsClass ? "class" :
type.IsEnum ? "enum" :
type.IsValueType ? "struct" : "type";
Console.WriteLine($" [{typeKind}] {type.Name}");
// Show attributes
var attrs = type.GetCustomAttributes(false);
if (attrs.Any())
{
foreach (var attr in attrs)
{
Console.WriteLine($" @{attr.GetType().Name}");
}
}
}
}
// Look for specific patterns
Console.WriteLine("\n\n=== Looking for MCP-Specific Patterns ===");
Console.WriteLine(new string('-', 60));
// Look for Tool-related types
var toolTypes = types.Where(t => t.Name.Contains("Tool", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nTool-related types ({toolTypes.Count()}):");
foreach (var t in toolTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Resource-related types
var resourceTypes = types.Where(t => t.Name.Contains("Resource", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nResource-related types ({resourceTypes.Count()}):");
foreach (var t in resourceTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Attribute types
var attributeTypes = types.Where(t => t.Name.EndsWith("Attribute", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nAttribute types ({attributeTypes.Count()}):");
foreach (var t in attributeTypes)
{
Console.WriteLine($" - {t.Name}");
}
// Look for Server-related types
var serverTypes = types.Where(t => t.Name.Contains("Server", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nServer-related types ({serverTypes.Count()}):");
foreach (var t in serverTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Client-related types
var clientTypes = types.Where(t => t.Name.Contains("Client", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nClient-related types ({clientTypes.Count()}):");
foreach (var t in clientTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Transport-related types
var transportTypes = types.Where(t => t.Name.Contains("Transport", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nTransport-related types ({transportTypes.Count()}):");
foreach (var t in transportTypes)
{
Console.WriteLine($" - {t.FullName}");
}
Console.WriteLine("\n=== Exploration Complete ===");

View File

@@ -13,6 +13,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.4.0-preview.3" />
<PackageReference Include="Scalar.AspNetCore" Version="2.9.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>

View File

@@ -13,18 +13,13 @@ namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/mcp/keys")]
[Authorize] // Requires JWT authentication
public class McpApiKeysController : ControllerBase
public class McpApiKeysController(
IMcpApiKeyService apiKeyService,
ILogger<McpApiKeysController> logger)
: ControllerBase
{
private readonly IMcpApiKeyService _apiKeyService;
private readonly ILogger<McpApiKeysController> _logger;
public McpApiKeysController(
IMcpApiKeyService apiKeyService,
ILogger<McpApiKeysController> logger)
{
_apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpApiKeyService _apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
private readonly ILogger<McpApiKeysController> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
/// <summary>
/// Create a new API Key

View File

@@ -14,18 +14,13 @@ namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/mcp/pending-changes")]
[Authorize] // Requires JWT authentication
public class McpPendingChangesController : ControllerBase
public class McpPendingChangesController(
IPendingChangeService pendingChangeService,
ILogger<McpPendingChangesController> logger)
: ControllerBase
{
private readonly IPendingChangeService _pendingChangeService;
private readonly ILogger<McpPendingChangesController> _logger;
public McpPendingChangesController(
IPendingChangeService pendingChangeService,
ILogger<McpPendingChangesController> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
private readonly ILogger<McpPendingChangesController> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
/// <summary>
/// Get list of pending changes with filtering and pagination

View File

@@ -7,15 +7,8 @@ namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class SignalRTestController : ControllerBase
public class SignalRTestController(IRealtimeNotificationService notificationService) : ControllerBase
{
private readonly IRealtimeNotificationService _notificationService;
public SignalRTestController(IRealtimeNotificationService notificationService)
{
_notificationService = notificationService;
}
/// <summary>
/// Test sending notification to current user
/// </summary>
@@ -24,7 +17,7 @@ public class SignalRTestController : ControllerBase
{
var userId = Guid.Parse(User.FindFirst("sub")!.Value);
await _notificationService.NotifyUser(userId, message, "test");
await notificationService.NotifyUser(userId, message, "test");
return Ok(new { message = "Notification sent", userId });
}
@@ -37,7 +30,7 @@ public class SignalRTestController : ControllerBase
{
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
await _notificationService.NotifyUsersInTenant(tenantId, message, "test");
await notificationService.NotifyUsersInTenant(tenantId, message, "test");
return Ok(new { message = "Tenant notification sent", tenantId });
}
@@ -50,7 +43,7 @@ public class SignalRTestController : ControllerBase
{
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
await _notificationService.NotifyProjectUpdate(tenantId, request.ProjectId, new
await notificationService.NotifyProjectUpdate(tenantId, request.ProjectId, new
{
Message = request.Message,
UpdatedBy = User.FindFirst("sub")!.Value,
@@ -68,7 +61,7 @@ public class SignalRTestController : ControllerBase
{
var tenantId = Guid.Parse(User.FindFirst("tenant_id")!.Value);
await _notificationService.NotifyIssueStatusChanged(
await notificationService.NotifyIssueStatusChanged(
tenantId,
request.ProjectId,
request.IssueId,

View File

@@ -22,22 +22,15 @@ namespace ColaFlow.API.Controllers;
[ApiController]
[Route("api/v1/sprints")]
[Authorize]
public class SprintsController : ControllerBase
public class SprintsController(IMediator mediator) : ControllerBase
{
private readonly IMediator _mediator;
public SprintsController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Create a new sprint
/// </summary>
[HttpPost]
public async Task<ActionResult<SprintDto>> Create([FromBody] CreateSprintCommand command)
{
var result = await _mediator.Send(command);
var result = await mediator.Send(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
@@ -50,7 +43,7 @@ public class SprintsController : ControllerBase
if (id != command.SprintId)
return BadRequest("Sprint ID mismatch");
await _mediator.Send(command);
await mediator.Send(command);
return NoContent();
}
@@ -60,7 +53,7 @@ public class SprintsController : ControllerBase
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid id)
{
await _mediator.Send(new DeleteSprintCommand(id));
await mediator.Send(new DeleteSprintCommand(id));
return NoContent();
}
@@ -70,7 +63,7 @@ public class SprintsController : ControllerBase
[HttpGet("{id}")]
public async Task<ActionResult<SprintDto>> GetById(Guid id)
{
var result = await _mediator.Send(new GetSprintByIdQuery(id));
var result = await mediator.Send(new GetSprintByIdQuery(id));
if (result == null)
return NotFound();
return Ok(result);
@@ -82,7 +75,7 @@ public class SprintsController : ControllerBase
[HttpGet]
public async Task<ActionResult<IReadOnlyList<SprintDto>>> GetByProject([FromQuery] Guid projectId)
{
var result = await _mediator.Send(new GetSprintsByProjectIdQuery(projectId));
var result = await mediator.Send(new GetSprintsByProjectIdQuery(projectId));
return Ok(result);
}
@@ -92,7 +85,7 @@ public class SprintsController : ControllerBase
[HttpGet("active")]
public async Task<ActionResult<IReadOnlyList<SprintDto>>> GetActive()
{
var result = await _mediator.Send(new GetActiveSprintsQuery());
var result = await mediator.Send(new GetActiveSprintsQuery());
return Ok(result);
}
@@ -102,7 +95,7 @@ public class SprintsController : ControllerBase
[HttpPost("{id}/start")]
public async Task<IActionResult> Start(Guid id)
{
await _mediator.Send(new StartSprintCommand(id));
await mediator.Send(new StartSprintCommand(id));
return NoContent();
}
@@ -112,7 +105,7 @@ public class SprintsController : ControllerBase
[HttpPost("{id}/complete")]
public async Task<IActionResult> Complete(Guid id)
{
await _mediator.Send(new CompleteSprintCommand(id));
await mediator.Send(new CompleteSprintCommand(id));
return NoContent();
}
@@ -122,7 +115,7 @@ public class SprintsController : ControllerBase
[HttpPost("{id}/tasks/{taskId}")]
public async Task<IActionResult> AddTask(Guid id, Guid taskId)
{
await _mediator.Send(new AddTaskToSprintCommand(id, taskId));
await mediator.Send(new AddTaskToSprintCommand(id, taskId));
return NoContent();
}
@@ -132,7 +125,7 @@ public class SprintsController : ControllerBase
[HttpDelete("{id}/tasks/{taskId}")]
public async Task<IActionResult> RemoveTask(Guid id, Guid taskId)
{
await _mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
await mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
return NoContent();
}
@@ -142,7 +135,7 @@ public class SprintsController : ControllerBase
[HttpGet("{id}/burndown")]
public async Task<ActionResult<BurndownChartDto>> GetBurndown(Guid id)
{
var result = await _mediator.Send(new GetSprintBurndownQuery(id));
var result = await mediator.Send(new GetSprintBurndownQuery(id));
if (result == null)
return NotFound();
return Ok(result);

View File

@@ -8,26 +8,20 @@ namespace ColaFlow.API.EventHandlers;
/// <summary>
/// Handles Sprint domain events and sends SignalR notifications
/// </summary>
public class SprintEventHandlers :
INotificationHandler<SprintCreatedEvent>,
INotificationHandler<SprintUpdatedEvent>,
INotificationHandler<SprintStartedEvent>,
INotificationHandler<SprintCompletedEvent>,
INotificationHandler<SprintDeletedEvent>
public class SprintEventHandlers(
IRealtimeNotificationService notificationService,
ILogger<SprintEventHandlers> logger,
IHttpContextAccessor httpContextAccessor)
:
INotificationHandler<SprintCreatedEvent>,
INotificationHandler<SprintUpdatedEvent>,
INotificationHandler<SprintStartedEvent>,
INotificationHandler<SprintCompletedEvent>,
INotificationHandler<SprintDeletedEvent>
{
private readonly IRealtimeNotificationService _notificationService;
private readonly ILogger<SprintEventHandlers> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
public SprintEventHandlers(
IRealtimeNotificationService notificationService,
ILogger<SprintEventHandlers> logger,
IHttpContextAccessor httpContextAccessor)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
}
private readonly IRealtimeNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
private readonly ILogger<SprintEventHandlers> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
public async Task Handle(SprintCreatedEvent notification, CancellationToken cancellationToken)
{

View File

@@ -66,11 +66,13 @@ public static class ModuleExtensions
services.AddScoped<ColaFlow.Modules.ProjectManagement.Application.Services.IProjectPermissionService,
ColaFlow.Modules.ProjectManagement.Infrastructure.Services.ProjectPermissionService>();
// Register MediatR handlers from Application assembly (v13.x syntax)
// Register MediatR handlers from Application assemblies (v13.x syntax)
// Consolidate all module handler registrations here to avoid duplicate registrations
services.AddMediatR(cfg =>
{
cfg.LicenseKey = configuration["MediatR:LicenseKey"];
cfg.RegisterServicesFromAssembly(typeof(CreateProjectCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.EventHandlers.PendingChangeApprovedEventHandler).Assembly);
});
// Register FluentValidation validators

View File

@@ -6,15 +6,8 @@ namespace ColaFlow.API.Hubs;
/// <summary>
/// Project real-time collaboration Hub
/// </summary>
public class ProjectHub : BaseHub
public class ProjectHub(IProjectPermissionService permissionService) : BaseHub
{
private readonly IProjectPermissionService _permissionService;
public ProjectHub(IProjectPermissionService permissionService)
{
_permissionService = permissionService;
}
/// <summary>
/// Join project room (to receive project-level updates)
/// </summary>
@@ -24,7 +17,7 @@ public class ProjectHub : BaseHub
var userId = GetCurrentUserId();
// Validate user has permission to access this project
var hasPermission = await _permissionService.IsUserProjectMemberAsync(
var hasPermission = await permissionService.IsUserProjectMemberAsync(
userId, projectId, Context.ConnectionAborted);
if (!hasPermission)
@@ -54,7 +47,7 @@ public class ProjectHub : BaseHub
var userId = GetCurrentUserId();
// Validate user has permission to access this project (for consistency)
var hasPermission = await _permissionService.IsUserProjectMemberAsync(
var hasPermission = await permissionService.IsUserProjectMemberAsync(
userId, projectId, Context.ConnectionAborted);
if (!hasPermission)

View File

@@ -0,0 +1,54 @@
// PoC file to test Microsoft ModelContextProtocol SDK Resources
// This demonstrates the SDK's attribute-based resource registration
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace ColaFlow.API.Mcp.Sdk;
/// <summary>
/// PoC class to test Microsoft MCP SDK Resource registration
/// NOTE: McpServerResource attribute MUST be on methods, not properties
/// </summary>
[McpServerResourceType]
public class SdkPocResources
{
/// <summary>
/// Simple resource method to test SDK attribute system
/// </summary>
[McpServerResource]
[Description("Check MCP SDK integration status")]
public static Task<string> GetSdkStatus()
{
return Task.FromResult("""
{
"status": "active",
"sdk": "Microsoft.ModelContextProtocol",
"version": "0.4.0-preview.3",
"message": "SDK integration working!"
}
""");
}
/// <summary>
/// Resource method to test health check
/// </summary>
[McpServerResource]
[Description("Health check resource")]
public static Task<string> GetHealthCheck()
{
var healthData = new
{
healthy = true,
timestamp = DateTime.UtcNow,
components = new[]
{
new { name = "MCP SDK", status = "operational" },
new { name = "Attribute Discovery", status = "operational" },
new { name = "DI Integration", status = "testing" }
}
};
return Task.FromResult(System.Text.Json.JsonSerializer.Serialize(healthData));
}
}

View File

@@ -0,0 +1,60 @@
// PoC file to test Microsoft ModelContextProtocol SDK
// This demonstrates the SDK's attribute-based tool registration
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace ColaFlow.API.Mcp.Sdk;
/// <summary>
/// PoC class to test Microsoft MCP SDK Tool registration
/// </summary>
[McpServerToolType]
public class SdkPocTools
{
/// <summary>
/// Simple ping tool to test SDK attribute system
/// </summary>
[McpServerTool]
[Description("Test tool that returns a pong message")]
public static Task<string> Ping()
{
return Task.FromResult("Pong from Microsoft MCP SDK!");
}
/// <summary>
/// Tool with parameters to test SDK parameter marshalling
/// </summary>
[McpServerTool]
[Description("Get project information by ID")]
public static Task<object> GetProjectInfo(
[Description("Project ID")] Guid projectId,
[Description("Include archived projects")] bool includeArchived = false)
{
return Task.FromResult<object>(new
{
projectId,
name = "SDK PoC Project",
status = "active",
includeArchived,
message = "This is a PoC response from Microsoft MCP SDK"
});
}
/// <summary>
/// Tool with dependency injection to test SDK DI integration
/// </summary>
[McpServerTool]
[Description("Get server time to test dependency injection")]
public static Task<object> GetServerTime(ILogger<SdkPocTools> logger)
{
logger.LogInformation("GetServerTime tool called via Microsoft MCP SDK");
return Task.FromResult<object>(new
{
serverTime = DateTime.UtcNow,
message = "Dependency injection works!",
sdkVersion = "0.4.0-preview.3"
});
}
}

View File

@@ -0,0 +1,26 @@
// Temporary file to explore ModelContextProtocol SDK APIs
// This file will be deleted after exploration
using ModelContextProtocol;
using Microsoft.Extensions.DependencyInjection;
namespace ColaFlow.API.Exploration;
/// <summary>
/// Temporary class to explore ModelContextProtocol SDK APIs
/// </summary>
public class McpSdkExplorer
{
public void ExploreServices(IServiceCollection services)
{
// Try to discover SDK extension methods
// services.AddMcp...
// services.AddModelContext...
}
public void ExploreTypes()
{
// List all types we can discover
// var type = typeof(???);
}
}

View File

@@ -5,21 +5,12 @@ namespace ColaFlow.API.Middleware;
/// <summary>
/// Middleware to log slow HTTP requests for performance monitoring
/// </summary>
public class PerformanceLoggingMiddleware
public class PerformanceLoggingMiddleware(
RequestDelegate next,
ILogger<PerformanceLoggingMiddleware> logger,
IConfiguration configuration)
{
private readonly RequestDelegate _next;
private readonly ILogger<PerformanceLoggingMiddleware> _logger;
private readonly int _slowRequestThresholdMs;
public PerformanceLoggingMiddleware(
RequestDelegate next,
ILogger<PerformanceLoggingMiddleware> logger,
IConfiguration configuration)
{
_next = next;
_logger = logger;
_slowRequestThresholdMs = configuration.GetValue<int>("Performance:SlowRequestThresholdMs", 1000);
}
private readonly int _slowRequestThresholdMs = configuration.GetValue<int>("Performance:SlowRequestThresholdMs", 1000);
public async Task InvokeAsync(HttpContext context)
{
@@ -29,7 +20,7 @@ public class PerformanceLoggingMiddleware
try
{
await _next(context);
await next(context);
}
finally
{
@@ -39,7 +30,7 @@ public class PerformanceLoggingMiddleware
// Log slow requests as warnings
if (elapsedMs > _slowRequestThresholdMs)
{
_logger.LogWarning(
logger.LogWarning(
"Slow request detected: {Method} {Path} took {ElapsedMs}ms (Status: {StatusCode})",
requestMethod,
requestPath,
@@ -49,7 +40,7 @@ public class PerformanceLoggingMiddleware
else if (elapsedMs > _slowRequestThresholdMs / 2)
{
// Log moderately slow requests as information
_logger.LogInformation(
logger.LogInformation(
"Request took {ElapsedMs}ms: {Method} {Path} (Status: {StatusCode})",
elapsedMs,
requestMethod,

View File

@@ -45,9 +45,18 @@ builder.Services.AddIssueManagementModule(builder.Configuration, builder.Environ
builder.Services.AddIdentityApplication();
builder.Services.AddIdentityInfrastructure(builder.Configuration, builder.Environment);
// Register MCP Module
// Register MCP Module (Custom Implementation - Keep for Diff Preview services)
builder.Services.AddMcpModule(builder.Configuration);
// ============================================
// Register Microsoft MCP SDK (Official)
// ============================================
builder.Services.AddMcpServer()
.WithHttpTransport() // Required for MapMcp() endpoint
.WithToolsFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.SdkTools.CreateIssueSdkTool).Assembly)
.WithResourcesFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.SdkResources.ProjectsSdkResource).Assembly)
.WithPromptsFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.SdkPrompts.ProjectManagementPrompts).Assembly);
// Add Response Caching
builder.Services.AddResponseCaching();
builder.Services.AddMemoryCache();
@@ -119,7 +128,8 @@ builder.Services.AddAuthentication(options =>
return Task.CompletedTask;
}
};
});
})
.AddMcpApiKeyAuthentication(); // Add MCP API Key authentication scheme
// Configure Authorization Policies for RBAC
builder.Services.AddAuthorization(options =>
@@ -144,6 +154,11 @@ builder.Services.AddAuthorization(options =>
// AI Agent only (for MCP integration testing)
options.AddPolicy("RequireAIAgent", policy =>
policy.RequireRole("AIAgent"));
// MCP API Key authentication policy (for /mcp-sdk endpoint)
options.AddPolicy("RequireMcpApiKey", policy =>
policy.AddAuthenticationSchemes(ColaFlow.Modules.Mcp.Infrastructure.Authentication.McpApiKeyAuthenticationOptions.DefaultScheme)
.RequireAuthenticatedUser());
});
// Configure CORS for frontend (SignalR requires AllowCredentials)
@@ -228,6 +243,13 @@ app.MapHub<ProjectHub>("/hubs/project");
app.MapHub<NotificationHub>("/hubs/notification");
app.MapHub<McpNotificationHub>("/hubs/mcp-notifications");
// ============================================
// Map MCP SDK Endpoint with API Key Authentication
// ============================================
app.MapMcp("/mcp-sdk")
.RequireAuthorization("RequireMcpApiKey"); // Require MCP API Key authentication
// Note: Legacy /mcp endpoint still handled by UseMcpMiddleware() above
// ============================================
// Auto-migrate databases in development
// ============================================

View File

@@ -6,81 +6,75 @@ namespace ColaFlow.API.Services;
/// Adapter that implements IProjectNotificationService by delegating to IRealtimeNotificationService
/// This allows the ProjectManagement module to send notifications without depending on the API layer
/// </summary>
public class ProjectNotificationServiceAdapter : IProjectNotificationService
public class ProjectNotificationServiceAdapter(IRealtimeNotificationService realtimeService)
: IProjectNotificationService
{
private readonly IRealtimeNotificationService _realtimeService;
public ProjectNotificationServiceAdapter(IRealtimeNotificationService realtimeService)
{
_realtimeService = realtimeService;
}
// Project notifications
public Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project)
{
return _realtimeService.NotifyProjectCreated(tenantId, projectId, project);
return realtimeService.NotifyProjectCreated(tenantId, projectId, project);
}
public Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project)
{
return _realtimeService.NotifyProjectUpdated(tenantId, projectId, project);
return realtimeService.NotifyProjectUpdated(tenantId, projectId, project);
}
public Task NotifyProjectArchived(Guid tenantId, Guid projectId)
{
return _realtimeService.NotifyProjectArchived(tenantId, projectId);
return realtimeService.NotifyProjectArchived(tenantId, projectId);
}
// Epic notifications
public Task NotifyEpicCreated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
return _realtimeService.NotifyEpicCreated(tenantId, projectId, epicId, epic);
return realtimeService.NotifyEpicCreated(tenantId, projectId, epicId, epic);
}
public Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
return _realtimeService.NotifyEpicUpdated(tenantId, projectId, epicId, epic);
return realtimeService.NotifyEpicUpdated(tenantId, projectId, epicId, epic);
}
public Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId)
{
return _realtimeService.NotifyEpicDeleted(tenantId, projectId, epicId);
return realtimeService.NotifyEpicDeleted(tenantId, projectId, epicId);
}
// Story notifications
public Task NotifyStoryCreated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
return _realtimeService.NotifyStoryCreated(tenantId, projectId, epicId, storyId, story);
return realtimeService.NotifyStoryCreated(tenantId, projectId, epicId, storyId, story);
}
public Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
return _realtimeService.NotifyStoryUpdated(tenantId, projectId, epicId, storyId, story);
return realtimeService.NotifyStoryUpdated(tenantId, projectId, epicId, storyId, story);
}
public Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId)
{
return _realtimeService.NotifyStoryDeleted(tenantId, projectId, epicId, storyId);
return realtimeService.NotifyStoryDeleted(tenantId, projectId, epicId, storyId);
}
// Task notifications
public Task NotifyTaskCreated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
return _realtimeService.NotifyTaskCreated(tenantId, projectId, storyId, taskId, task);
return realtimeService.NotifyTaskCreated(tenantId, projectId, storyId, taskId, task);
}
public Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
return _realtimeService.NotifyTaskUpdated(tenantId, projectId, storyId, taskId, task);
return realtimeService.NotifyTaskUpdated(tenantId, projectId, storyId, taskId, task);
}
public Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId)
{
return _realtimeService.NotifyTaskDeleted(tenantId, projectId, storyId, taskId);
return realtimeService.NotifyTaskDeleted(tenantId, projectId, storyId, taskId);
}
public Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId)
{
return _realtimeService.NotifyTaskAssigned(tenantId, projectId, taskId, assigneeId);
return realtimeService.NotifyTaskAssigned(tenantId, projectId, taskId, assigneeId);
}
}

View File

@@ -3,29 +3,19 @@ using ColaFlow.API.Hubs;
namespace ColaFlow.API.Services;
public class RealtimeNotificationService : IRealtimeNotificationService
public class RealtimeNotificationService(
IHubContext<ProjectHub> projectHubContext,
IHubContext<NotificationHub> notificationHubContext,
ILogger<RealtimeNotificationService> logger)
: IRealtimeNotificationService
{
private readonly IHubContext<ProjectHub> _projectHubContext;
private readonly IHubContext<NotificationHub> _notificationHubContext;
private readonly ILogger<RealtimeNotificationService> _logger;
public RealtimeNotificationService(
IHubContext<ProjectHub> projectHubContext,
IHubContext<NotificationHub> notificationHubContext,
ILogger<RealtimeNotificationService> logger)
{
_projectHubContext = projectHubContext;
_notificationHubContext = notificationHubContext;
_logger = logger;
}
public async Task NotifyProjectCreated(Guid tenantId, Guid projectId, object project)
{
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying tenant {TenantId} of new project {ProjectId}", tenantId, projectId);
logger.LogInformation("Notifying tenant {TenantId} of new project {ProjectId}", tenantId, projectId);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectCreated", project);
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectCreated", project);
}
public async Task NotifyProjectUpdated(Guid tenantId, Guid projectId, object project)
@@ -33,10 +23,10 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying project {ProjectId} updated", projectId);
logger.LogInformation("Notifying project {ProjectId} updated", projectId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectUpdated", project);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectUpdated", project);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectUpdated", project);
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectUpdated", project);
}
public async Task NotifyProjectArchived(Guid tenantId, Guid projectId)
@@ -44,19 +34,19 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying project {ProjectId} archived", projectId);
logger.LogInformation("Notifying project {ProjectId} archived", projectId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectArchived", new { ProjectId = projectId });
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectArchived", new { ProjectId = projectId });
await projectHubContext.Clients.Group(projectGroupName).SendAsync("ProjectArchived", new { ProjectId = projectId });
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("ProjectArchived", new { ProjectId = projectId });
}
public async Task NotifyProjectUpdate(Guid tenantId, Guid projectId, object data)
{
var groupName = $"project-{projectId}";
_logger.LogInformation("Sending project update to group {GroupName}", groupName);
logger.LogInformation("Sending project update to group {GroupName}", groupName);
await _projectHubContext.Clients.Group(groupName).SendAsync("ProjectUpdated", data);
await projectHubContext.Clients.Group(groupName).SendAsync("ProjectUpdated", data);
}
// Epic notifications
@@ -65,28 +55,28 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying epic {EpicId} created in project {ProjectId}", epicId, projectId);
logger.LogInformation("Notifying epic {EpicId} created in project {ProjectId}", epicId, projectId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicCreated", epic);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("EpicCreated", epic);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicCreated", epic);
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("EpicCreated", epic);
}
public async Task NotifyEpicUpdated(Guid tenantId, Guid projectId, Guid epicId, object epic)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying epic {EpicId} updated", epicId);
logger.LogInformation("Notifying epic {EpicId} updated", epicId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicUpdated", epic);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicUpdated", epic);
}
public async Task NotifyEpicDeleted(Guid tenantId, Guid projectId, Guid epicId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying epic {EpicId} deleted", epicId);
logger.LogInformation("Notifying epic {EpicId} deleted", epicId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicDeleted", new { EpicId = epicId });
await projectHubContext.Clients.Group(projectGroupName).SendAsync("EpicDeleted", new { EpicId = epicId });
}
// Story notifications
@@ -95,28 +85,28 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying story {StoryId} created in epic {EpicId}", storyId, epicId);
logger.LogInformation("Notifying story {StoryId} created in epic {EpicId}", storyId, epicId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryCreated", story);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("StoryCreated", story);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryCreated", story);
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("StoryCreated", story);
}
public async Task NotifyStoryUpdated(Guid tenantId, Guid projectId, Guid epicId, Guid storyId, object story)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying story {StoryId} updated", storyId);
logger.LogInformation("Notifying story {StoryId} updated", storyId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryUpdated", story);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryUpdated", story);
}
public async Task NotifyStoryDeleted(Guid tenantId, Guid projectId, Guid epicId, Guid storyId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying story {StoryId} deleted", storyId);
logger.LogInformation("Notifying story {StoryId} deleted", storyId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryDeleted", new { StoryId = storyId });
await projectHubContext.Clients.Group(projectGroupName).SendAsync("StoryDeleted", new { StoryId = storyId });
}
// Task notifications
@@ -125,37 +115,37 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying task {TaskId} created in story {StoryId}", taskId, storyId);
logger.LogInformation("Notifying task {TaskId} created in story {StoryId}", taskId, storyId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskCreated", task);
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("TaskCreated", task);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskCreated", task);
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("TaskCreated", task);
}
public async Task NotifyTaskUpdated(Guid tenantId, Guid projectId, Guid storyId, Guid taskId, object task)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying task {TaskId} updated", taskId);
logger.LogInformation("Notifying task {TaskId} updated", taskId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskUpdated", task);
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskUpdated", task);
}
public async Task NotifyTaskDeleted(Guid tenantId, Guid projectId, Guid storyId, Guid taskId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying task {TaskId} deleted", taskId);
logger.LogInformation("Notifying task {TaskId} deleted", taskId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskDeleted", new { TaskId = taskId });
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskDeleted", new { TaskId = taskId });
}
public async Task NotifyTaskAssigned(Guid tenantId, Guid projectId, Guid taskId, Guid assigneeId)
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying task {TaskId} assigned to {AssigneeId}", taskId, assigneeId);
logger.LogInformation("Notifying task {TaskId} assigned to {AssigneeId}", taskId, assigneeId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskAssigned", new
await projectHubContext.Clients.Group(projectGroupName).SendAsync("TaskAssigned", new
{
TaskId = taskId,
AssigneeId = assigneeId,
@@ -167,21 +157,21 @@ public class RealtimeNotificationService : IRealtimeNotificationService
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueCreated", issue);
await projectHubContext.Clients.Group(groupName).SendAsync("IssueCreated", issue);
}
public async Task NotifyIssueUpdated(Guid tenantId, Guid projectId, object issue)
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueUpdated", issue);
await projectHubContext.Clients.Group(groupName).SendAsync("IssueUpdated", issue);
}
public async Task NotifyIssueDeleted(Guid tenantId, Guid projectId, Guid issueId)
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueDeleted", new { IssueId = issueId });
await projectHubContext.Clients.Group(groupName).SendAsync("IssueDeleted", new { IssueId = issueId });
}
public async Task NotifyIssueStatusChanged(
@@ -193,7 +183,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
{
var groupName = $"project-{projectId}";
await _projectHubContext.Clients.Group(groupName).SendAsync("IssueStatusChanged", new
await projectHubContext.Clients.Group(groupName).SendAsync("IssueStatusChanged", new
{
IssueId = issueId,
OldStatus = oldStatus,
@@ -208,9 +198,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying sprint {SprintId} created in project {ProjectId}", sprintId, projectId);
logger.LogInformation("Notifying sprint {SprintId} created in project {ProjectId}", sprintId, projectId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCreated", new
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCreated", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -218,7 +208,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
Timestamp = DateTime.UtcNow
});
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCreated", new
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCreated", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -231,9 +221,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
{
var projectGroupName = $"project-{projectId}";
_logger.LogInformation("Notifying sprint {SprintId} updated", sprintId);
logger.LogInformation("Notifying sprint {SprintId} updated", sprintId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintUpdated", new
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintUpdated", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -247,9 +237,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying sprint {SprintId} started", sprintId);
logger.LogInformation("Notifying sprint {SprintId} started", sprintId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintStarted", new
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintStarted", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -257,7 +247,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
Timestamp = DateTime.UtcNow
});
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintStarted", new
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintStarted", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -271,9 +261,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying sprint {SprintId} completed", sprintId);
logger.LogInformation("Notifying sprint {SprintId} completed", sprintId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCompleted", new
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintCompleted", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -281,7 +271,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
Timestamp = DateTime.UtcNow
});
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCompleted", new
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintCompleted", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -295,9 +285,9 @@ public class RealtimeNotificationService : IRealtimeNotificationService
var projectGroupName = $"project-{projectId}";
var tenantGroupName = $"tenant-{tenantId}";
_logger.LogInformation("Notifying sprint {SprintId} deleted", sprintId);
logger.LogInformation("Notifying sprint {SprintId} deleted", sprintId);
await _projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintDeleted", new
await projectHubContext.Clients.Group(projectGroupName).SendAsync("SprintDeleted", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -305,7 +295,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
Timestamp = DateTime.UtcNow
});
await _projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintDeleted", new
await projectHubContext.Clients.Group(tenantGroupName).SendAsync("SprintDeleted", new
{
SprintId = sprintId,
SprintName = sprintName,
@@ -318,7 +308,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
{
var userConnectionId = $"user-{userId}";
await _notificationHubContext.Clients.User(userId.ToString()).SendAsync("Notification", new
await notificationHubContext.Clients.User(userId.ToString()).SendAsync("Notification", new
{
Message = message,
Type = type,
@@ -330,7 +320,7 @@ public class RealtimeNotificationService : IRealtimeNotificationService
{
var groupName = $"tenant-{tenantId}";
await _notificationHubContext.Clients.Group(groupName).SendAsync("Notification", new
await notificationHubContext.Clients.Group(groupName).SendAsync("Notification", new
{
Message = message,
Type = type,

View File

@@ -8,38 +8,22 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.AcceptInvitation;
public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCommand, Guid>
public class AcceptInvitationCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<AcceptInvitationCommandHandler> logger)
: IRequestHandler<AcceptInvitationCommand, Guid>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<AcceptInvitationCommandHandler> _logger;
public AcceptInvitationCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<AcceptInvitationCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_userTenantRoleRepository = userTenantRoleRepository;
_tokenService = tokenService;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task<Guid> Handle(AcceptInvitationCommand request, CancellationToken cancellationToken)
{
// Hash the token to find the invitation
var tokenHash = _tokenService.HashToken(request.Token);
var tokenHash = tokenService.HashToken(request.Token);
// Find invitation by token hash
var invitation = await _invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
var invitation = await invitationRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (invitation == null)
throw new InvalidOperationException("Invalid invitation token");
@@ -50,14 +34,14 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
var fullName = FullName.Create(request.FullName);
// Check if user already exists in this tenant
var existingUser = await _userRepository.GetByEmailAsync(invitation.TenantId, email, cancellationToken);
var existingUser = await userRepository.GetByEmailAsync(invitation.TenantId, email, cancellationToken);
User user;
if (existingUser != null)
{
// User already exists in this tenant
user = existingUser;
_logger.LogInformation(
logger.LogInformation(
"User {UserId} already exists in tenant {TenantId}, adding role",
user.Id,
invitation.TenantId);
@@ -65,16 +49,16 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
else
{
// Create new user
var passwordHash = _passwordHasher.HashPassword(request.Password);
var passwordHash = passwordHasher.HashPassword(request.Password);
user = User.CreateLocal(
invitation.TenantId,
email,
passwordHash,
fullName);
await _userRepository.AddAsync(user, cancellationToken);
await userRepository.AddAsync(user, cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Created new user {UserId} for invitation acceptance in tenant {TenantId}",
user.Id,
invitation.TenantId);
@@ -82,7 +66,7 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
// Check if user already has a role in this tenant
var userId = UserId.Create(user.Id);
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
var existingRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
userId,
invitation.TenantId,
cancellationToken);
@@ -91,9 +75,9 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
{
// User already has a role - update it
existingRole.UpdateRole(invitation.Role, user.Id);
await _userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
await userTenantRoleRepository.UpdateAsync(existingRole, cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Updated role for user {UserId} in tenant {TenantId} to {Role}",
user.Id,
invitation.TenantId,
@@ -108,9 +92,9 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
invitation.Role,
invitation.InvitedBy);
await _userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
await userTenantRoleRepository.AddAsync(userTenantRole, cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Created role mapping for user {UserId} in tenant {TenantId} with role {Role}",
user.Id,
invitation.TenantId,
@@ -119,9 +103,9 @@ public class AcceptInvitationCommandHandler : IRequestHandler<AcceptInvitationCo
// Mark invitation as accepted
invitation.Accept();
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
await invitationRepository.UpdateAsync(invitation, cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Invitation {InvitationId} accepted by user {UserId}",
invitation.Id,
user.Id);

View File

@@ -6,26 +6,18 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.CancelInvitation;
public class CancelInvitationCommandHandler : IRequestHandler<CancelInvitationCommand, Unit>
public class CancelInvitationCommandHandler(
IInvitationRepository invitationRepository,
ILogger<CancelInvitationCommandHandler> logger)
: IRequestHandler<CancelInvitationCommand, Unit>
{
private readonly IInvitationRepository _invitationRepository;
private readonly ILogger<CancelInvitationCommandHandler> _logger;
public CancelInvitationCommandHandler(
IInvitationRepository invitationRepository,
ILogger<CancelInvitationCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_logger = logger;
}
public async Task<Unit> Handle(CancelInvitationCommand request, CancellationToken cancellationToken)
{
var invitationId = InvitationId.Create(request.InvitationId);
var tenantId = TenantId.Create(request.TenantId);
// Get the invitation
var invitation = await _invitationRepository.GetByIdAsync(invitationId, cancellationToken);
var invitation = await invitationRepository.GetByIdAsync(invitationId, cancellationToken);
if (invitation == null)
throw new InvalidOperationException($"Invitation {request.InvitationId} not found");
@@ -35,9 +27,9 @@ public class CancelInvitationCommandHandler : IRequestHandler<CancelInvitationCo
// Cancel the invitation
invitation.Cancel();
await _invitationRepository.UpdateAsync(invitation, cancellationToken);
await invitationRepository.UpdateAsync(invitation, cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Invitation {InvitationId} cancelled by user {CancelledBy} in tenant {TenantId}",
request.InvitationId,
request.CancelledBy,

View File

@@ -9,42 +9,22 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.ForgotPassword;
public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordCommand, Unit>
public class ForgotPasswordCommandHandler(
IUserRepository userRepository,
ITenantRepository tenantRepository,
IPasswordResetTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService emailTemplateService,
IRateLimitService rateLimitService,
ILogger<ForgotPasswordCommandHandler> logger)
: IRequestHandler<ForgotPasswordCommand, Unit>
{
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
private readonly IPasswordResetTokenRepository _tokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _emailTemplateService;
private readonly IRateLimitService _rateLimitService;
private readonly ILogger<ForgotPasswordCommandHandler> _logger;
public ForgotPasswordCommandHandler(
IUserRepository userRepository,
ITenantRepository tenantRepository,
IPasswordResetTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService emailTemplateService,
IRateLimitService rateLimitService,
ILogger<ForgotPasswordCommandHandler> logger)
{
_userRepository = userRepository;
_tenantRepository = tenantRepository;
_tokenRepository = tokenRepository;
_tokenService = tokenService;
_emailService = emailService;
_emailTemplateService = emailTemplateService;
_rateLimitService = rateLimitService;
_logger = logger;
}
public async Task<Unit> Handle(ForgotPasswordCommand request, CancellationToken cancellationToken)
{
// Rate limiting: 3 requests per hour per email
var rateLimitKey = $"forgot-password:{request.Email.ToLowerInvariant()}";
var isAllowed = await _rateLimitService.IsAllowedAsync(
var isAllowed = await rateLimitService.IsAllowedAsync(
rateLimitKey,
3,
TimeSpan.FromHours(1),
@@ -52,7 +32,7 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
if (!isAllowed)
{
_logger.LogWarning(
logger.LogWarning(
"Rate limit exceeded for forgot password. Email: {Email}, IP: {IpAddress}",
request.Email,
request.IpAddress);
@@ -69,15 +49,15 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
}
catch (ArgumentException ex)
{
_logger.LogWarning("Invalid tenant slug: {TenantSlug} - {Error}", request.TenantSlug, ex.Message);
logger.LogWarning("Invalid tenant slug: {TenantSlug} - {Error}", request.TenantSlug, ex.Message);
// Return success to prevent enumeration
return Unit.Value;
}
var tenant = await _tenantRepository.GetBySlugAsync(tenantSlug, cancellationToken);
var tenant = await tenantRepository.GetBySlugAsync(tenantSlug, cancellationToken);
if (tenant == null)
{
_logger.LogWarning("Tenant not found: {TenantSlug}", request.TenantSlug);
logger.LogWarning("Tenant not found: {TenantSlug}", request.TenantSlug);
// Return success to prevent enumeration
return Unit.Value;
}
@@ -90,15 +70,15 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
}
catch (ArgumentException ex)
{
_logger.LogWarning("Invalid email: {Email} - {Error}", request.Email, ex.Message);
logger.LogWarning("Invalid email: {Email} - {Error}", request.Email, ex.Message);
// Return success to prevent enumeration
return Unit.Value;
}
var user = await _userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
var user = await userRepository.GetByEmailAsync(TenantId.Create(tenant.Id), email, cancellationToken);
if (user == null)
{
_logger.LogWarning(
logger.LogWarning(
"User not found for password reset. Email: {Email}, Tenant: {TenantSlug}",
request.Email,
request.TenantSlug);
@@ -108,11 +88,11 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
}
// Invalidate all existing password reset tokens for this user
await _tokenRepository.InvalidateAllForUserAsync(UserId.Create(user.Id), cancellationToken);
await tokenRepository.InvalidateAllForUserAsync(UserId.Create(user.Id), cancellationToken);
// Generate new password reset token (1-hour expiration)
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
var token = tokenService.GenerateToken();
var tokenHash = tokenService.HashToken(token);
var expiresAt = DateTime.UtcNow.AddHours(1);
var resetToken = PasswordResetToken.Create(
@@ -122,13 +102,13 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
request.IpAddress,
request.UserAgent);
await _tokenRepository.AddAsync(resetToken, cancellationToken);
await tokenRepository.AddAsync(resetToken, cancellationToken);
// Construct reset URL
var resetUrl = $"{request.BaseUrl}/reset-password?token={token}";
// Send password reset email
var emailBody = _emailTemplateService.RenderPasswordResetEmail(
var emailBody = emailTemplateService.RenderPasswordResetEmail(
user.FullName.ToString(),
resetUrl);
@@ -138,18 +118,18 @@ public class ForgotPasswordCommandHandler : IRequestHandler<ForgotPasswordComman
HtmlBody: emailBody
);
var emailSent = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
var emailSent = await emailService.SendEmailAsync(emailMessage, cancellationToken);
if (emailSent)
{
_logger.LogInformation(
logger.LogInformation(
"Password reset email sent. UserId: {UserId}, Email: {Email}",
user.Id,
user.Email);
}
else
{
_logger.LogError(
logger.LogError(
"Failed to send password reset email. UserId: {UserId}, Email: {Email}",
user.Id,
user.Email);

View File

@@ -9,37 +9,17 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.InviteUser;
public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
public class InviteUserCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ITenantRepository tenantRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<InviteUserCommandHandler> logger)
: IRequestHandler<InviteUserCommand, Guid>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserTenantRoleRepository _userTenantRoleRepository;
private readonly ITenantRepository _tenantRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _templateService;
private readonly ILogger<InviteUserCommandHandler> _logger;
public InviteUserCommandHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
IUserTenantRoleRepository userTenantRoleRepository,
ITenantRepository tenantRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<InviteUserCommandHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_userTenantRoleRepository = userTenantRoleRepository;
_tenantRepository = tenantRepository;
_tokenService = tokenService;
_emailService = emailService;
_templateService = templateService;
_logger = logger;
}
public async Task<Guid> Handle(InviteUserCommand request, CancellationToken cancellationToken)
{
var tenantId = TenantId.Create(request.TenantId);
@@ -50,23 +30,23 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
throw new ArgumentException($"Invalid role: {request.Role}");
// Check if tenant exists
var tenant = await _tenantRepository.GetByIdAsync(tenantId, cancellationToken);
var tenant = await tenantRepository.GetByIdAsync(tenantId, cancellationToken);
if (tenant == null)
throw new InvalidOperationException($"Tenant {request.TenantId} not found");
// Check if inviter exists
var inviter = await _userRepository.GetByIdAsync(invitedBy, cancellationToken);
var inviter = await userRepository.GetByIdAsync(invitedBy, cancellationToken);
if (inviter == null)
throw new InvalidOperationException($"Inviter user {request.InvitedBy} not found");
var email = Email.Create(request.Email);
// Check if user already exists in this tenant
var existingUser = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
var existingUser = await userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
if (existingUser != null)
{
// Check if user already has a role in this tenant
var existingRole = await _userTenantRoleRepository.GetByUserAndTenantAsync(
var existingRole = await userTenantRoleRepository.GetByUserAndTenantAsync(
UserId.Create(existingUser.Id),
tenantId,
cancellationToken);
@@ -76,7 +56,7 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
}
// Check for existing pending invitation
var existingInvitation = await _invitationRepository.GetPendingByEmailAndTenantAsync(
var existingInvitation = await invitationRepository.GetPendingByEmailAndTenantAsync(
request.Email,
tenantId,
cancellationToken);
@@ -85,8 +65,8 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
throw new InvalidOperationException($"A pending invitation already exists for {request.Email} in this tenant");
// Generate secure token
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
var token = tokenService.GenerateToken();
var tokenHash = tokenService.HashToken(token);
// Create invitation
var invitation = Invitation.Create(
@@ -96,11 +76,11 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
tokenHash,
invitedBy);
await _invitationRepository.AddAsync(invitation, cancellationToken);
await invitationRepository.AddAsync(invitation, cancellationToken);
// Send invitation email
var invitationLink = $"{request.BaseUrl}/accept-invitation?token={token}";
var htmlBody = _templateService.RenderInvitationEmail(
var htmlBody = templateService.RenderInvitationEmail(
recipientName: request.Email.Split('@')[0], // Use email prefix as fallback name
tenantName: tenant.Name.Value,
inviterName: inviter.FullName.Value,
@@ -112,18 +92,18 @@ public class InviteUserCommandHandler : IRequestHandler<InviteUserCommand, Guid>
HtmlBody: htmlBody,
PlainTextBody: $"You've been invited to join {tenant.Name.Value}. Click here to accept: {invitationLink}");
var emailSuccess = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
var emailSuccess = await emailService.SendEmailAsync(emailMessage, cancellationToken);
if (!emailSuccess)
{
_logger.LogWarning(
logger.LogWarning(
"Failed to send invitation email to {Email} for tenant {TenantId}",
request.Email,
request.TenantId);
}
else
{
_logger.LogInformation(
logger.LogInformation(
"Invitation sent to {Email} for tenant {TenantId} with role {Role}",
request.Email,
request.TenantId,

View File

@@ -16,34 +16,16 @@ namespace ColaFlow.Modules.Identity.Application.Commands.ResendVerificationEmail
/// - Rate limiting (1 email per minute)
/// - Token rotation (invalidate old token)
/// </summary>
public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerificationEmailCommand, bool>
public class ResendVerificationEmailCommandHandler(
IUserRepository userRepository,
IEmailVerificationTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
IRateLimitService rateLimitService,
ILogger<ResendVerificationEmailCommandHandler> logger)
: IRequestHandler<ResendVerificationEmailCommand, bool>
{
private readonly IUserRepository _userRepository;
private readonly IEmailVerificationTokenRepository _tokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _templateService;
private readonly IRateLimitService _rateLimitService;
private readonly ILogger<ResendVerificationEmailCommandHandler> _logger;
public ResendVerificationEmailCommandHandler(
IUserRepository userRepository,
IEmailVerificationTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
IRateLimitService rateLimitService,
ILogger<ResendVerificationEmailCommandHandler> logger)
{
_userRepository = userRepository;
_tokenRepository = tokenRepository;
_tokenService = tokenService;
_emailService = emailService;
_templateService = templateService;
_rateLimitService = rateLimitService;
_logger = logger;
}
public async Task<bool> Handle(ResendVerificationEmailCommand request, CancellationToken cancellationToken)
{
try
@@ -51,25 +33,25 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
// 1. Find user by email and tenant (no enumeration - don't reveal if user exists)
var email = Email.Create(request.Email);
var tenantId = TenantId.Create(request.TenantId);
var user = await _userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
var user = await userRepository.GetByEmailAsync(tenantId, email, cancellationToken);
if (user == null)
{
// Email enumeration prevention: Don't reveal user doesn't exist
_logger.LogWarning("Resend verification requested for non-existent email: {Email}", request.Email);
logger.LogWarning("Resend verification requested for non-existent email: {Email}", request.Email);
return true; // Always return success
}
// 2. Check if already verified (success if so)
if (user.IsEmailVerified)
{
_logger.LogInformation("Email already verified for user {UserId}", user.Id);
logger.LogInformation("Email already verified for user {UserId}", user.Id);
return true; // Already verified - success
}
// 3. Check rate limit (1 email per minute per address)
var rateLimitKey = $"resend-verification:{request.Email}:{request.TenantId}";
var isAllowed = await _rateLimitService.IsAllowedAsync(
var isAllowed = await rateLimitService.IsAllowedAsync(
rateLimitKey,
maxAttempts: 1,
window: TimeSpan.FromMinutes(1),
@@ -77,15 +59,15 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
if (!isAllowed)
{
_logger.LogWarning(
logger.LogWarning(
"Rate limit exceeded for resend verification: {Email}",
request.Email);
return true; // Still return success to prevent enumeration
}
// 4. Generate new verification token with SHA-256 hashing
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
var token = tokenService.GenerateToken();
var tokenHash = tokenService.HashToken(token);
// 5. Invalidate old tokens by creating new one (token rotation)
var verificationToken = EmailVerificationToken.Create(
@@ -93,11 +75,11 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
tokenHash,
DateTime.UtcNow.AddHours(24)); // 24 hours expiration
await _tokenRepository.AddAsync(verificationToken, cancellationToken);
await tokenRepository.AddAsync(verificationToken, cancellationToken);
// 6. Send verification email
var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
var htmlBody = templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
var emailMessage = new EmailMessage(
To: request.Email,
@@ -105,18 +87,18 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
HtmlBody: htmlBody,
PlainTextBody: $"Click the link to verify your email: {verificationLink}");
var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
var success = await emailService.SendEmailAsync(emailMessage, cancellationToken);
if (!success)
{
_logger.LogWarning(
logger.LogWarning(
"Failed to send verification email to {Email} for user {UserId}",
request.Email,
user.Id);
}
else
{
_logger.LogInformation(
logger.LogInformation(
"Verification email resent to {Email} for user {UserId}",
request.Email,
user.Id);
@@ -127,7 +109,7 @@ public class ResendVerificationEmailCommandHandler : IRequestHandler<ResendVerif
}
catch (Exception ex)
{
_logger.LogError(
logger.LogError(
ex,
"Error resending verification email for {Email}",
request.Email);

View File

@@ -6,56 +6,38 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.ResetPassword;
public class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand, bool>
public class ResetPasswordCommandHandler(
IPasswordResetTokenRepository tokenRepository,
IUserRepository userRepository,
IRefreshTokenRepository refreshTokenRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<ResetPasswordCommandHandler> logger,
IPublisher publisher)
: IRequestHandler<ResetPasswordCommand, bool>
{
private readonly IPasswordResetTokenRepository _tokenRepository;
private readonly IUserRepository _userRepository;
private readonly IRefreshTokenRepository _refreshTokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<ResetPasswordCommandHandler> _logger;
private readonly IPublisher _publisher;
public ResetPasswordCommandHandler(
IPasswordResetTokenRepository tokenRepository,
IUserRepository userRepository,
IRefreshTokenRepository refreshTokenRepository,
ISecurityTokenService tokenService,
IPasswordHasher passwordHasher,
ILogger<ResetPasswordCommandHandler> logger,
IPublisher publisher)
{
_tokenRepository = tokenRepository;
_userRepository = userRepository;
_refreshTokenRepository = refreshTokenRepository;
_tokenService = tokenService;
_passwordHasher = passwordHasher;
_logger = logger;
_publisher = publisher;
}
public async Task<bool> Handle(ResetPasswordCommand request, CancellationToken cancellationToken)
{
// Validate new password
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
{
_logger.LogWarning("Invalid password provided for reset");
logger.LogWarning("Invalid password provided for reset");
return false;
}
// Hash the token to look it up
var tokenHash = _tokenService.HashToken(request.Token);
var resetToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
var tokenHash = tokenService.HashToken(request.Token);
var resetToken = await tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (resetToken == null)
{
_logger.LogWarning("Password reset token not found");
logger.LogWarning("Password reset token not found");
return false;
}
if (!resetToken.IsValid)
{
_logger.LogWarning(
logger.LogWarning(
"Password reset token is invalid. IsExpired: {IsExpired}, IsUsed: {IsUsed}",
resetToken.IsExpired,
resetToken.IsUsed);
@@ -63,36 +45,36 @@ public class ResetPasswordCommandHandler : IRequestHandler<ResetPasswordCommand,
}
// Get user
var user = await _userRepository.GetByIdAsync(resetToken.UserId, cancellationToken);
var user = await userRepository.GetByIdAsync(resetToken.UserId, cancellationToken);
if (user == null)
{
_logger.LogError("User {UserId} not found for password reset", resetToken.UserId);
logger.LogError("User {UserId} not found for password reset", resetToken.UserId);
return false;
}
// Hash the new password
var newPasswordHash = _passwordHasher.HashPassword(request.NewPassword);
var newPasswordHash = passwordHasher.HashPassword(request.NewPassword);
// Update user password (will emit UserPasswordChangedEvent)
user.UpdatePassword(newPasswordHash);
await _userRepository.UpdateAsync(user, cancellationToken);
await userRepository.UpdateAsync(user, cancellationToken);
// Mark token as used
resetToken.MarkAsUsed();
await _tokenRepository.UpdateAsync(resetToken, cancellationToken);
await tokenRepository.UpdateAsync(resetToken, cancellationToken);
// Revoke all refresh tokens for security (force re-login on all devices)
await _refreshTokenRepository.RevokeAllUserTokensAsync(
await refreshTokenRepository.RevokeAllUserTokensAsync(
(Guid)user.Id,
"Password reset",
cancellationToken);
// Publish domain event for audit logging
await _publisher.Publish(
await publisher.Publish(
new PasswordResetCompletedEvent((Guid)user.Id, resetToken.IpAddress),
cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Password reset successfully completed for user {UserId}. All refresh tokens revoked.",
(Guid)user.Id);

View File

@@ -8,52 +8,36 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.SendVerificationEmail;
public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificationEmailCommand, Unit>
public class SendVerificationEmailCommandHandler(
IUserRepository userRepository,
IEmailVerificationTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<SendVerificationEmailCommandHandler> logger)
: IRequestHandler<SendVerificationEmailCommand, Unit>
{
private readonly IUserRepository _userRepository;
private readonly IEmailVerificationTokenRepository _tokenRepository;
private readonly ISecurityTokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IEmailTemplateService _templateService;
private readonly ILogger<SendVerificationEmailCommandHandler> _logger;
public SendVerificationEmailCommandHandler(
IUserRepository userRepository,
IEmailVerificationTokenRepository tokenRepository,
ISecurityTokenService tokenService,
IEmailService emailService,
IEmailTemplateService templateService,
ILogger<SendVerificationEmailCommandHandler> logger)
{
_userRepository = userRepository;
_tokenRepository = tokenRepository;
_tokenService = tokenService;
_emailService = emailService;
_templateService = templateService;
_logger = logger;
}
public async Task<Unit> Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken)
{
var userId = UserId.Create(request.UserId);
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
var user = await userRepository.GetByIdAsync(userId, cancellationToken);
if (user == null)
{
_logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
logger.LogWarning("User {UserId} not found, cannot send verification email", request.UserId);
return Unit.Value;
}
// If already verified, no need to send email
if (user.IsEmailVerified)
{
_logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
logger.LogInformation("User {UserId} email already verified, skipping verification email", request.UserId);
return Unit.Value;
}
// Generate token
var token = _tokenService.GenerateToken();
var tokenHash = _tokenService.HashToken(token);
var token = tokenService.GenerateToken();
var tokenHash = tokenService.HashToken(token);
// Create verification token entity
var verificationToken = EmailVerificationToken.Create(
@@ -61,11 +45,11 @@ public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificat
tokenHash,
DateTime.UtcNow.AddHours(24));
await _tokenRepository.AddAsync(verificationToken, cancellationToken);
await tokenRepository.AddAsync(verificationToken, cancellationToken);
// Send email (non-blocking)
var verificationLink = $"{request.BaseUrl}/verify-email?token={token}";
var htmlBody = _templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
var htmlBody = templateService.RenderVerificationEmail(user.FullName.Value, verificationLink);
var emailMessage = new EmailMessage(
To: request.Email,
@@ -73,18 +57,18 @@ public class SendVerificationEmailCommandHandler : IRequestHandler<SendVerificat
HtmlBody: htmlBody,
PlainTextBody: $"Click the link to verify your email: {verificationLink}");
var success = await _emailService.SendEmailAsync(emailMessage, cancellationToken);
var success = await emailService.SendEmailAsync(emailMessage, cancellationToken);
if (!success)
{
_logger.LogWarning(
logger.LogWarning(
"Failed to send verification email to {Email} for user {UserId}",
request.Email,
request.UserId);
}
else
{
_logger.LogInformation(
logger.LogInformation(
"Verification email sent to {Email} for user {UserId}",
request.Email,
request.UserId);

View File

@@ -5,40 +5,28 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Commands.VerifyEmail;
public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, bool>
public class VerifyEmailCommandHandler(
IEmailVerificationTokenRepository tokenRepository,
IUserRepository userRepository,
ISecurityTokenService tokenService,
ILogger<VerifyEmailCommandHandler> logger)
: IRequestHandler<VerifyEmailCommand, bool>
{
private readonly IEmailVerificationTokenRepository _tokenRepository;
private readonly IUserRepository _userRepository;
private readonly ISecurityTokenService _tokenService;
private readonly ILogger<VerifyEmailCommandHandler> _logger;
public VerifyEmailCommandHandler(
IEmailVerificationTokenRepository tokenRepository,
IUserRepository userRepository,
ISecurityTokenService tokenService,
ILogger<VerifyEmailCommandHandler> logger)
{
_tokenRepository = tokenRepository;
_userRepository = userRepository;
_tokenService = tokenService;
_logger = logger;
}
public async Task<bool> Handle(VerifyEmailCommand request, CancellationToken cancellationToken)
{
// Hash the token to look it up
var tokenHash = _tokenService.HashToken(request.Token);
var verificationToken = await _tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
var tokenHash = tokenService.HashToken(request.Token);
var verificationToken = await tokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken);
if (verificationToken == null)
{
_logger.LogWarning("Email verification token not found");
logger.LogWarning("Email verification token not found");
return false;
}
if (!verificationToken.IsValid)
{
_logger.LogWarning(
logger.LogWarning(
"Email verification token is invalid. IsExpired: {IsExpired}, IsVerified: {IsVerified}",
verificationToken.IsExpired,
verificationToken.IsVerified);
@@ -46,22 +34,22 @@ public class VerifyEmailCommandHandler : IRequestHandler<VerifyEmailCommand, boo
}
// Get user and mark email as verified
var user = await _userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
var user = await userRepository.GetByIdAsync(verificationToken.UserId, cancellationToken);
if (user == null)
{
_logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
logger.LogError("User {UserId} not found for email verification", verificationToken.UserId);
return false;
}
// Mark token as verified
verificationToken.MarkAsVerified();
await _tokenRepository.UpdateAsync(verificationToken, cancellationToken);
await tokenRepository.UpdateAsync(verificationToken, cancellationToken);
// Mark user email as verified (will emit domain event)
user.VerifyEmail();
await _userRepository.UpdateAsync(user, cancellationToken);
await userRepository.UpdateAsync(user, cancellationToken);
_logger.LogInformation("Email verified for user {UserId}", user.Id);
logger.LogInformation("Email verified for user {UserId}", user.Id);
return true;
}

View File

@@ -7,18 +7,12 @@ namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for InvitationAcceptedEvent - logs acceptance
/// </summary>
public class InvitationAcceptedEventHandler : INotificationHandler<InvitationAcceptedEvent>
public class InvitationAcceptedEventHandler(ILogger<InvitationAcceptedEventHandler> logger)
: INotificationHandler<InvitationAcceptedEvent>
{
private readonly ILogger<InvitationAcceptedEventHandler> _logger;
public InvitationAcceptedEventHandler(ILogger<InvitationAcceptedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(InvitationAcceptedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"Invitation accepted: Email={Email}, Tenant={TenantId}, Role={Role}",
notification.Email,
notification.TenantId,

View File

@@ -7,18 +7,12 @@ namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for InvitationCancelledEvent - logs cancellation
/// </summary>
public class InvitationCancelledEventHandler : INotificationHandler<InvitationCancelledEvent>
public class InvitationCancelledEventHandler(ILogger<InvitationCancelledEventHandler> logger)
: INotificationHandler<InvitationCancelledEvent>
{
private readonly ILogger<InvitationCancelledEventHandler> _logger;
public InvitationCancelledEventHandler(ILogger<InvitationCancelledEventHandler> logger)
{
_logger = logger;
}
public Task Handle(InvitationCancelledEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"Invitation cancelled: Email={Email}, Tenant={TenantId}",
notification.Email,
notification.TenantId);

View File

@@ -7,18 +7,11 @@ namespace ColaFlow.Modules.Identity.Application.EventHandlers;
/// <summary>
/// Event handler for UserInvitedEvent - logs invitation
/// </summary>
public class UserInvitedEventHandler : INotificationHandler<UserInvitedEvent>
public class UserInvitedEventHandler(ILogger<UserInvitedEventHandler> logger) : INotificationHandler<UserInvitedEvent>
{
private readonly ILogger<UserInvitedEventHandler> _logger;
public UserInvitedEventHandler(ILogger<UserInvitedEventHandler> logger)
{
_logger = logger;
}
public Task Handle(UserInvitedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
logger.LogInformation(
"User invited: Email={Email}, Tenant={TenantId}, Role={Role}, InvitedBy={InvitedBy}",
notification.Email,
notification.TenantId,

View File

@@ -6,34 +6,24 @@ using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Identity.Application.Queries.GetPendingInvitations;
public class GetPendingInvitationsQueryHandler : IRequestHandler<GetPendingInvitationsQuery, List<InvitationDto>>
public class GetPendingInvitationsQueryHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
ILogger<GetPendingInvitationsQueryHandler> logger)
: IRequestHandler<GetPendingInvitationsQuery, List<InvitationDto>>
{
private readonly IInvitationRepository _invitationRepository;
private readonly IUserRepository _userRepository;
private readonly ILogger<GetPendingInvitationsQueryHandler> _logger;
public GetPendingInvitationsQueryHandler(
IInvitationRepository invitationRepository,
IUserRepository userRepository,
ILogger<GetPendingInvitationsQueryHandler> logger)
{
_invitationRepository = invitationRepository;
_userRepository = userRepository;
_logger = logger;
}
public async Task<List<InvitationDto>> Handle(GetPendingInvitationsQuery request, CancellationToken cancellationToken)
{
var tenantId = TenantId.Create(request.TenantId);
// Get all pending invitations for the tenant
var invitations = await _invitationRepository.GetPendingByTenantAsync(tenantId, cancellationToken);
var invitations = await invitationRepository.GetPendingByTenantAsync(tenantId, cancellationToken);
// Get all unique inviter user IDs
var inviterIds = invitations.Select(i => (Guid)i.InvitedBy).Distinct().ToList();
// Fetch all inviters in one query
var inviters = await _userRepository.GetByIdsAsync(inviterIds, cancellationToken);
var inviters = await userRepository.GetByIdsAsync(inviterIds, cancellationToken);
var inviterDict = inviters.ToDictionary(u => u.Id, u => u.FullName.Value);
// Map to DTOs
@@ -47,7 +37,7 @@ public class GetPendingInvitationsQueryHandler : IRequestHandler<GetPendingInvit
ExpiresAt: i.ExpiresAt
)).ToList();
_logger.LogInformation(
logger.LogInformation(
"Retrieved {Count} pending invitations for tenant {TenantId}",
dtos.Count,
request.TenantId);

View File

@@ -11,19 +11,11 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// Persists rate limit state in PostgreSQL to survive server restarts.
/// Prevents email bombing attacks even after application restart.
/// </summary>
public class DatabaseEmailRateLimiter : IRateLimitService
public class DatabaseEmailRateLimiter(
IdentityDbContext context,
ILogger<DatabaseEmailRateLimiter> logger)
: IRateLimitService
{
private readonly IdentityDbContext _context;
private readonly ILogger<DatabaseEmailRateLimiter> _logger;
public DatabaseEmailRateLimiter(
IdentityDbContext context,
ILogger<DatabaseEmailRateLimiter> logger)
{
_context = context;
_logger = logger;
}
public async Task<bool> IsAllowedAsync(
string key,
int maxAttempts,
@@ -39,7 +31,7 @@ public class DatabaseEmailRateLimiter : IRateLimitService
var parts = key.Split(':');
if (parts.Length != 3)
{
_logger.LogWarning("Invalid rate limit key format: {Key}. Expected format: 'operation:email:tenantId'", key);
logger.LogWarning("Invalid rate limit key format: {Key}. Expected format: 'operation:email:tenantId'", key);
return true; // Fail open (allow request) if key format is invalid
}
@@ -49,12 +41,12 @@ public class DatabaseEmailRateLimiter : IRateLimitService
if (!Guid.TryParse(tenantIdStr, out var tenantId))
{
_logger.LogWarning("Invalid tenant ID in rate limit key: {Key}", key);
logger.LogWarning("Invalid tenant ID in rate limit key: {Key}", key);
return true; // Fail open
}
// Find existing rate limit record
var rateLimit = await _context.EmailRateLimits
var rateLimit = await context.EmailRateLimits
.FirstOrDefaultAsync(
r => r.Email == email &&
r.TenantId == tenantId &&
@@ -65,23 +57,23 @@ public class DatabaseEmailRateLimiter : IRateLimitService
if (rateLimit == null)
{
var newRateLimit = EmailRateLimit.Create(email, tenantId, operationType);
_context.EmailRateLimits.Add(newRateLimit);
context.EmailRateLimits.Add(newRateLimit);
try
{
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
await context.SaveChangesAsync(cancellationToken);
logger.LogInformation(
"Rate limit record created for {Email} - {Operation} (Attempt 1/{MaxAttempts})",
email, operationType, maxAttempts);
}
catch (DbUpdateException ex)
{
// Handle race condition: another request created the record simultaneously
_logger.LogWarning(ex,
logger.LogWarning(ex,
"Race condition detected while creating rate limit record for {Key}. Retrying...", key);
// Re-fetch the record created by the concurrent request
rateLimit = await _context.EmailRateLimits
rateLimit = await context.EmailRateLimits
.FirstOrDefaultAsync(
r => r.Email == email &&
r.TenantId == tenantId &&
@@ -90,7 +82,7 @@ public class DatabaseEmailRateLimiter : IRateLimitService
if (rateLimit == null)
{
_logger.LogError("Failed to fetch rate limit record after race condition for {Key}", key);
logger.LogError("Failed to fetch rate limit record after race condition for {Key}", key);
return true; // Fail open
}
@@ -106,10 +98,10 @@ public class DatabaseEmailRateLimiter : IRateLimitService
{
// Window expired - reset counter and allow
rateLimit.ResetAttempts();
_context.EmailRateLimits.Update(rateLimit);
await _context.SaveChangesAsync(cancellationToken);
context.EmailRateLimits.Update(rateLimit);
await context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Rate limit window expired for {Email} - {Operation}. Counter reset (Attempt 1/{MaxAttempts})",
email, operationType, maxAttempts);
@@ -122,7 +114,7 @@ public class DatabaseEmailRateLimiter : IRateLimitService
// Rate limit exceeded
var remainingTime = window - (DateTime.UtcNow - rateLimit.LastSentAt);
_logger.LogWarning(
logger.LogWarning(
"Rate limit EXCEEDED for {Email} - {Operation}: {Attempts}/{MaxAttempts} attempts. " +
"Retry after {RemainingSeconds} seconds",
email, operationType, rateLimit.AttemptsCount, maxAttempts,
@@ -133,10 +125,10 @@ public class DatabaseEmailRateLimiter : IRateLimitService
// Still within limit - increment counter and allow
rateLimit.RecordAttempt();
_context.EmailRateLimits.Update(rateLimit);
await _context.SaveChangesAsync(cancellationToken);
context.EmailRateLimits.Update(rateLimit);
await context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Rate limit check passed for {Email} - {Operation} (Attempt {Attempts}/{MaxAttempts})",
email, operationType, rateLimit.AttemptsCount, maxAttempts);
@@ -150,16 +142,16 @@ public class DatabaseEmailRateLimiter : IRateLimitService
{
var cutoffDate = DateTime.UtcNow - retentionPeriod;
var expiredRecords = await _context.EmailRateLimits
var expiredRecords = await context.EmailRateLimits
.Where(r => r.LastSentAt < cutoffDate)
.ToListAsync(cancellationToken);
if (expiredRecords.Any())
{
_context.EmailRateLimits.RemoveRange(expiredRecords);
await _context.SaveChangesAsync(cancellationToken);
context.EmailRateLimits.RemoveRange(expiredRecords);
await context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Cleaned up {Count} expired rate limit records older than {CutoffDate}",
expiredRecords.Count, cutoffDate);
}

View File

@@ -7,15 +7,8 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// In-memory rate limiting service implementation.
/// For production, consider using Redis for distributed rate limiting.
/// </summary>
public class MemoryRateLimitService : IRateLimitService
public class MemoryRateLimitService(IMemoryCache cache) : IRateLimitService
{
private readonly IMemoryCache _cache;
public MemoryRateLimitService(IMemoryCache cache)
{
_cache = cache;
}
public Task<bool> IsAllowedAsync(
string key,
int maxAttempts,
@@ -25,7 +18,7 @@ public class MemoryRateLimitService : IRateLimitService
var cacheKey = $"ratelimit:{key}";
// Get current attempt count from cache
var attempts = _cache.GetOrCreate(cacheKey, entry =>
var attempts = cache.GetOrCreate(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = window;
return 0;
@@ -38,7 +31,7 @@ public class MemoryRateLimitService : IRateLimitService
}
// Increment attempt count
_cache.Set(cacheKey, attempts + 1, new MemoryCacheEntryOptions
cache.Set(cacheKey, attempts + 1, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = window
});

View File

@@ -8,9 +8,8 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// Mock email service for development/testing that logs emails instead of sending them
/// Captures sent emails for testing purposes
/// </summary>
public sealed class MockEmailService : IEmailService
public sealed class MockEmailService(ILogger<MockEmailService> logger) : IEmailService
{
private readonly ILogger<MockEmailService> _logger;
private readonly List<EmailMessage> _sentEmails = new();
/// <summary>
@@ -18,23 +17,18 @@ public sealed class MockEmailService : IEmailService
/// </summary>
public IReadOnlyList<EmailMessage> SentEmails => _sentEmails.AsReadOnly();
public MockEmailService(ILogger<MockEmailService> logger)
{
_logger = logger;
}
public Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
// Capture the email for testing
_sentEmails.Add(message);
_logger.LogInformation(
logger.LogInformation(
"[MOCK EMAIL] To: {To}, Subject: {Subject}, From: {From}",
message.To,
message.Subject,
message.FromEmail ?? "default");
_logger.LogDebug(
logger.LogDebug(
"[MOCK EMAIL] HTML Body: {HtmlBody}",
message.HtmlBody);

View File

@@ -10,31 +10,23 @@ namespace ColaFlow.Modules.Identity.Infrastructure.Services;
/// <summary>
/// SMTP-based email service for production use
/// </summary>
public sealed class SmtpEmailService : IEmailService
public sealed class SmtpEmailService(
ILogger<SmtpEmailService> logger,
IConfiguration configuration)
: IEmailService
{
private readonly ILogger<SmtpEmailService> _logger;
private readonly IConfiguration _configuration;
public SmtpEmailService(
ILogger<SmtpEmailService> logger,
IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
}
public async Task<bool> SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
try
{
var smtpHost = _configuration["Email:Smtp:Host"];
var smtpPort = int.Parse(_configuration["Email:Smtp:Port"] ?? "587");
var smtpUsername = _configuration["Email:Smtp:Username"];
var smtpPassword = _configuration["Email:Smtp:Password"];
var enableSsl = bool.Parse(_configuration["Email:Smtp:EnableSsl"] ?? "true");
var smtpHost = configuration["Email:Smtp:Host"];
var smtpPort = int.Parse(configuration["Email:Smtp:Port"] ?? "587");
var smtpUsername = configuration["Email:Smtp:Username"];
var smtpPassword = configuration["Email:Smtp:Password"];
var enableSsl = bool.Parse(configuration["Email:Smtp:EnableSsl"] ?? "true");
var defaultFromEmail = _configuration["Email:From"] ?? "noreply@colaflow.local";
var defaultFromName = _configuration["Email:FromName"] ?? "ColaFlow";
var defaultFromEmail = configuration["Email:From"] ?? "noreply@colaflow.local";
var defaultFromName = configuration["Email:FromName"] ?? "ColaFlow";
using var smtpClient = new SmtpClient(smtpHost, smtpPort)
{
@@ -66,7 +58,7 @@ public sealed class SmtpEmailService : IEmailService
await smtpClient.SendMailAsync(mailMessage, cancellationToken);
_logger.LogInformation(
logger.LogInformation(
"Email sent successfully to {To} with subject: {Subject}",
message.To,
message.Subject);
@@ -75,7 +67,7 @@ public sealed class SmtpEmailService : IEmailService
}
catch (Exception ex)
{
_logger.LogError(
logger.LogError(
ex,
"Failed to send email to {To} with subject: {Subject}",
message.To,

View File

@@ -18,7 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
</ItemGroup>
</Project>

View File

@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is applied
/// </summary>
public class PendingChangeAppliedNotificationHandler : INotificationHandler<PendingChangeAppliedEvent>
public class PendingChangeAppliedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeAppliedNotificationHandler> logger)
: INotificationHandler<PendingChangeAppliedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeAppliedNotificationHandler> _logger;
public PendingChangeAppliedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeAppliedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
private readonly ILogger<PendingChangeAppliedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task Handle(PendingChangeAppliedEvent notification, CancellationToken cancellationToken)
{

View File

@@ -19,21 +19,15 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// Event handler for PendingChangeApprovedEvent
/// Executes the approved change by dispatching appropriate commands
/// </summary>
public class PendingChangeApprovedEventHandler : INotificationHandler<PendingChangeApprovedEvent>
public class PendingChangeApprovedEventHandler(
IMediator mediator,
IPendingChangeService pendingChangeService,
ILogger<PendingChangeApprovedEventHandler> logger)
: INotificationHandler<PendingChangeApprovedEvent>
{
private readonly IMediator _mediator;
private readonly IPendingChangeService _pendingChangeService;
private readonly ILogger<PendingChangeApprovedEventHandler> _logger;
public PendingChangeApprovedEventHandler(
IMediator mediator,
IPendingChangeService pendingChangeService,
ILogger<PendingChangeApprovedEventHandler> logger)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMediator _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
private readonly ILogger<PendingChangeApprovedEventHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task Handle(PendingChangeApprovedEvent notification, CancellationToken cancellationToken)
{

View File

@@ -10,18 +10,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// Event handler that sends SignalR notifications when a PendingChange is approved
/// Runs in parallel with PendingChangeApprovedEventHandler (which executes the change)
/// </summary>
public class PendingChangeApprovedNotificationHandler : INotificationHandler<PendingChangeApprovedEvent>
public class PendingChangeApprovedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeApprovedNotificationHandler> logger)
: INotificationHandler<PendingChangeApprovedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeApprovedNotificationHandler> _logger;
public PendingChangeApprovedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeApprovedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
private readonly ILogger<PendingChangeApprovedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task Handle(PendingChangeApprovedEvent notification, CancellationToken cancellationToken)
{

View File

@@ -11,21 +11,15 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is created
/// </summary>
public class PendingChangeCreatedNotificationHandler : INotificationHandler<PendingChangeCreatedEvent>
public class PendingChangeCreatedNotificationHandler(
IMcpNotificationService notificationService,
IPendingChangeRepository repository,
ILogger<PendingChangeCreatedNotificationHandler> logger)
: INotificationHandler<PendingChangeCreatedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly IPendingChangeRepository _repository;
private readonly ILogger<PendingChangeCreatedNotificationHandler> _logger;
public PendingChangeCreatedNotificationHandler(
IMcpNotificationService notificationService,
IPendingChangeRepository repository,
ILogger<PendingChangeCreatedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
private readonly IPendingChangeRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
private readonly ILogger<PendingChangeCreatedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task Handle(PendingChangeCreatedEvent notification, CancellationToken cancellationToken)
{

View File

@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange expires
/// </summary>
public class PendingChangeExpiredNotificationHandler : INotificationHandler<PendingChangeExpiredEvent>
public class PendingChangeExpiredNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeExpiredNotificationHandler> logger)
: INotificationHandler<PendingChangeExpiredEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeExpiredNotificationHandler> _logger;
public PendingChangeExpiredNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeExpiredNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
private readonly ILogger<PendingChangeExpiredNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task Handle(PendingChangeExpiredEvent notification, CancellationToken cancellationToken)
{

View File

@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is rejected
/// </summary>
public class PendingChangeRejectedNotificationHandler : INotificationHandler<PendingChangeRejectedEvent>
public class PendingChangeRejectedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeRejectedNotificationHandler> logger)
: INotificationHandler<PendingChangeRejectedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeRejectedNotificationHandler> _logger;
public PendingChangeRejectedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeRejectedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpNotificationService _notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
private readonly ILogger<PendingChangeRejectedNotificationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task Handle(PendingChangeRejectedEvent notification, CancellationToken cancellationToken)
{

View File

@@ -7,17 +7,10 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// <summary>
/// Handler for the 'initialize' MCP method
/// </summary>
public class InitializeMethodHandler : IMcpMethodHandler
public class InitializeMethodHandler(ILogger<InitializeMethodHandler> logger) : IMcpMethodHandler
{
private readonly ILogger<InitializeMethodHandler> _logger;
public string MethodName => "initialize";
public InitializeMethodHandler(ILogger<InitializeMethodHandler> logger)
{
_logger = logger;
}
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
{
try
@@ -30,7 +23,7 @@ public class InitializeMethodHandler : IMcpMethodHandler
initRequest = JsonSerializer.Deserialize<McpInitializeRequest>(json);
}
_logger.LogInformation(
logger.LogInformation(
"MCP Initialize handshake received. Client: {ClientName} {ClientVersion}, Protocol: {ProtocolVersion}",
initRequest?.ClientInfo?.Name ?? "Unknown",
initRequest?.ClientInfo?.Version ?? "Unknown",
@@ -39,7 +32,7 @@ public class InitializeMethodHandler : IMcpMethodHandler
// Validate protocol version
if (initRequest?.ProtocolVersion != "1.0")
{
_logger.LogWarning("Unsupported protocol version: {ProtocolVersion}", initRequest?.ProtocolVersion);
logger.LogWarning("Unsupported protocol version: {ProtocolVersion}", initRequest?.ProtocolVersion);
}
// Create initialize response
@@ -54,13 +47,13 @@ public class InitializeMethodHandler : IMcpMethodHandler
Capabilities = McpServerCapabilities.CreateDefault()
};
_logger.LogInformation("MCP Initialize handshake completed successfully");
logger.LogInformation("MCP Initialize handshake completed successfully");
return Task.FromResult<object?>(response);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling initialize request");
logger.LogError(ex, "Error handling initialize request");
throw;
}
}

View File

@@ -8,26 +8,18 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// Handler for 'resources/health' method
/// Checks availability and health of all registered resources
/// </summary>
public class ResourceHealthCheckHandler : IMcpMethodHandler
public class ResourceHealthCheckHandler(
ILogger<ResourceHealthCheckHandler> logger,
IMcpResourceRegistry resourceRegistry)
: IMcpMethodHandler
{
private readonly ILogger<ResourceHealthCheckHandler> _logger;
private readonly IMcpResourceRegistry _resourceRegistry;
public string MethodName => "resources/health";
public ResourceHealthCheckHandler(
ILogger<ResourceHealthCheckHandler> logger,
IMcpResourceRegistry resourceRegistry)
{
_logger = logger;
_resourceRegistry = resourceRegistry;
}
public async Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
{
_logger.LogDebug("Handling resources/health request");
logger.LogDebug("Handling resources/health request");
var resources = _resourceRegistry.GetAllResources();
var resources = resourceRegistry.GetAllResources();
var healthResults = new List<object>();
var totalResources = resources.Count;
var healthyResources = 0;
@@ -67,7 +59,7 @@ public class ResourceHealthCheckHandler : IMcpMethodHandler
catch (Exception ex)
{
unhealthyResources++;
_logger.LogError(ex, "Health check failed for resource {ResourceType}", resource.GetType().Name);
logger.LogError(ex, "Health check failed for resource {ResourceType}", resource.GetType().Name);
healthResults.Add(new
{
@@ -82,7 +74,7 @@ public class ResourceHealthCheckHandler : IMcpMethodHandler
var overallStatus = unhealthyResources == 0 ? "healthy" : "degraded";
_logger.LogInformation("Resource health check completed: {Healthy}/{Total} healthy",
logger.LogInformation("Resource health check completed: {Healthy}/{Total} healthy",
healthyResources, totalResources);
var response = new

View File

@@ -7,30 +7,22 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// Handler for the 'resources/list' MCP method
/// Returns categorized list of all available resources with full metadata
/// </summary>
public class ResourcesListMethodHandler : IMcpMethodHandler
public class ResourcesListMethodHandler(
ILogger<ResourcesListMethodHandler> logger,
IMcpResourceRegistry resourceRegistry)
: IMcpMethodHandler
{
private readonly ILogger<ResourcesListMethodHandler> _logger;
private readonly IMcpResourceRegistry _resourceRegistry;
public string MethodName => "resources/list";
public ResourcesListMethodHandler(
ILogger<ResourcesListMethodHandler> logger,
IMcpResourceRegistry resourceRegistry)
{
_logger = logger;
_resourceRegistry = resourceRegistry;
}
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
{
_logger.LogDebug("Handling resources/list request");
logger.LogDebug("Handling resources/list request");
// Get all registered resource descriptors with full metadata
var descriptors = _resourceRegistry.GetResourceDescriptors();
var categories = _resourceRegistry.GetCategories();
var descriptors = resourceRegistry.GetResourceDescriptors();
var categories = resourceRegistry.GetCategories();
_logger.LogInformation("Returning {Count} MCP resources in {CategoryCount} categories",
logger.LogInformation("Returning {Count} MCP resources in {CategoryCount} categories",
descriptors.Count, categories.Count);
// Group by category for better organization

View File

@@ -9,25 +9,19 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// <summary>
/// Handler for the 'resources/read' MCP method
/// Uses scoped IMcpResource instances from DI to avoid DbContext disposal issues
/// </summary>
public class ResourcesReadMethodHandler : IMcpMethodHandler
public class ResourcesReadMethodHandler(
ILogger<ResourcesReadMethodHandler> logger,
IMcpResourceRegistry resourceRegistry,
IEnumerable<IMcpResource> scopedResources)
: IMcpMethodHandler
{
private readonly ILogger<ResourcesReadMethodHandler> _logger;
private readonly IMcpResourceRegistry _resourceRegistry;
public string MethodName => "resources/read";
public ResourcesReadMethodHandler(
ILogger<ResourcesReadMethodHandler> logger,
IMcpResourceRegistry resourceRegistry)
{
_logger = logger;
_resourceRegistry = resourceRegistry;
}
public async Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
{
_logger.LogDebug("Handling resources/read request");
logger.LogDebug("Handling resources/read request");
// Parse parameters
var paramsJson = JsonSerializer.Serialize(@params);
@@ -38,15 +32,22 @@ public class ResourcesReadMethodHandler : IMcpMethodHandler
throw new McpInvalidParamsException("Missing required parameter: uri");
}
_logger.LogInformation("Reading resource: {Uri}", request.Uri);
logger.LogInformation("Reading resource: {Uri}", request.Uri);
// Find resource by URI
var resource = _resourceRegistry.GetResourceByUri(request.Uri);
if (resource == null)
// Find resource descriptor from registry (for URI template matching)
var registryResource = resourceRegistry.GetResourceByUri(request.Uri);
if (registryResource == null)
{
throw new McpNotFoundException($"Resource not found: {request.Uri}");
}
// Get the scoped resource instance from DI (fresh DbContext)
var resource = scopedResources.FirstOrDefault(r => r.Uri == registryResource.Uri);
if (resource == null)
{
throw new McpNotFoundException($"Resource implementation not found: {registryResource.Uri}");
}
// Parse URI and extract parameters
var resourceRequest = ParseResourceRequest(request.Uri, resource.Uri);
@@ -122,6 +123,7 @@ public class ResourcesReadMethodHandler : IMcpMethodHandler
private class ResourceReadParams
{
[System.Text.Json.Serialization.JsonPropertyName("uri")]
public string Uri { get; set; } = string.Empty;
}
}

View File

@@ -5,20 +5,13 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// <summary>
/// Handler for the 'tools/call' MCP method
/// </summary>
public class ToolsCallMethodHandler : IMcpMethodHandler
public class ToolsCallMethodHandler(ILogger<ToolsCallMethodHandler> logger) : IMcpMethodHandler
{
private readonly ILogger<ToolsCallMethodHandler> _logger;
public string MethodName => "tools/call";
public ToolsCallMethodHandler(ILogger<ToolsCallMethodHandler> logger)
{
_logger = logger;
}
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
{
_logger.LogDebug("Handling tools/call request");
logger.LogDebug("Handling tools/call request");
// TODO: Implement in Story 5.11 (Core MCP Tools)
// For now, return error

View File

@@ -5,20 +5,13 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// <summary>
/// Handler for the 'tools/list' MCP method
/// </summary>
public class ToolsListMethodHandler : IMcpMethodHandler
public class ToolsListMethodHandler(ILogger<ToolsListMethodHandler> logger) : IMcpMethodHandler
{
private readonly ILogger<ToolsListMethodHandler> _logger;
public string MethodName => "tools/list";
public ToolsListMethodHandler(ILogger<ToolsListMethodHandler> logger)
{
_logger = logger;
}
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
{
_logger.LogDebug("Handling tools/list request");
logger.LogDebug("Handling tools/list request");
// TODO: Implement in Story 5.11 (Core MCP Tools)
// For now, return empty list

View File

@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
/// Resource: colaflow://issues.get/{id}
/// Gets detailed information about a specific issue (Epic, Story, or Task)
/// </summary>
public class IssuesGetResource : IMcpResource
public class IssuesGetResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<IssuesGetResource> logger)
: IMcpResource
{
public string Uri => "colaflow://issues.get/{id}";
public string Name => "Issue Details";
@@ -21,25 +25,11 @@ public class IssuesGetResource : IMcpResource
public string Category => "Issues";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<IssuesGetResource> _logger;
public IssuesGetResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<IssuesGetResource> logger)
{
_projectRepository = projectRepository;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<McpResourceContent> GetContentAsync(
McpResourceRequest request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var tenantId = tenantContext.GetCurrentTenantId();
// Extract {id} from URI parameters
if (!request.UriParams.TryGetValue("id", out var idString))
@@ -52,10 +42,10 @@ public class IssuesGetResource : IMcpResource
throw new McpInvalidParamsException($"Invalid issue ID format: {idString}");
}
_logger.LogDebug("Fetching issue {IssueId} for tenant {TenantId}", issueIdGuid, tenantId);
logger.LogDebug("Fetching issue {IssueId} for tenant {TenantId}", issueIdGuid, tenantId);
// Try to find as Epic
var epic = await _projectRepository.GetEpicByIdReadOnlyAsync(EpicId.From(issueIdGuid), cancellationToken);
var epic = await projectRepository.GetEpicByIdReadOnlyAsync(EpicId.From(issueIdGuid), cancellationToken);
if (epic != null)
{
var epicDto = new
@@ -89,7 +79,7 @@ public class IssuesGetResource : IMcpResource
}
// Try to find as Story
var story = await _projectRepository.GetStoryByIdReadOnlyAsync(StoryId.From(issueIdGuid), cancellationToken);
var story = await projectRepository.GetStoryByIdReadOnlyAsync(StoryId.From(issueIdGuid), cancellationToken);
if (story != null)
{
var storyDto = new
@@ -124,7 +114,7 @@ public class IssuesGetResource : IMcpResource
}
// Try to find as Task
var task = await _projectRepository.GetTaskByIdReadOnlyAsync(TaskId.From(issueIdGuid), cancellationToken);
var task = await projectRepository.GetTaskByIdReadOnlyAsync(TaskId.From(issueIdGuid), cancellationToken);
if (task != null)
{
var taskDto = new

View File

@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
/// Searches issues with filters (Epics, Stories, Tasks)
/// Query params: status, priority, assignee, type, project, limit, offset
/// </summary>
public class IssuesSearchResource : IMcpResource
public class IssuesSearchResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<IssuesSearchResource> logger)
: IMcpResource
{
public string Uri => "colaflow://issues.search";
public string Name => "Issues Search";
@@ -21,27 +25,13 @@ public class IssuesSearchResource : IMcpResource
public string Category => "Issues";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<IssuesSearchResource> _logger;
public IssuesSearchResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<IssuesSearchResource> logger)
{
_projectRepository = projectRepository;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<McpResourceContent> GetContentAsync(
McpResourceRequest request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var tenantId = tenantContext.GetCurrentTenantId();
_logger.LogDebug("Searching issues for tenant {TenantId} with filters: {@Filters}",
logger.LogDebug("Searching issues for tenant {TenantId} with filters: {@Filters}",
tenantId, request.QueryParams);
// Parse query parameters
@@ -57,13 +47,13 @@ public class IssuesSearchResource : IMcpResource
limit = Math.Min(limit, 100);
// Get all projects
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
var projects = await projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
// Filter by project if specified
if (!string.IsNullOrEmpty(projectFilter) && Guid.TryParse(projectFilter, out var projectIdGuid))
{
var projectId = ProjectId.From(projectIdGuid);
var project = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
var project = await projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
projects = project != null ? new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project> { project } : new();
}
else
@@ -72,7 +62,7 @@ public class IssuesSearchResource : IMcpResource
var projectsWithHierarchy = new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project>();
foreach (var p in projects)
{
var fullProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
var fullProject = await projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
if (fullProject != null)
{
projectsWithHierarchy.Add(fullProject);
@@ -180,7 +170,7 @@ public class IssuesSearchResource : IMcpResource
offset = offset
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Found {Count} issues for tenant {TenantId} (total: {Total})",
logger.LogInformation("Found {Count} issues for tenant {TenantId} (total: {Total})",
paginatedIssues.Count, tenantId, total);
return new McpResourceContent

View File

@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
/// Resource: colaflow://projects.get/{id}
/// Gets detailed information about a specific project
/// </summary>
public class ProjectsGetResource : IMcpResource
public class ProjectsGetResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<ProjectsGetResource> logger)
: IMcpResource
{
public string Uri => "colaflow://projects.get/{id}";
public string Name => "Project Details";
@@ -21,20 +25,6 @@ public class ProjectsGetResource : IMcpResource
public string Category => "Projects";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ProjectsGetResource> _logger;
public ProjectsGetResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<ProjectsGetResource> logger)
{
_projectRepository = projectRepository;
_tenantContext = tenantContext;
_logger = logger;
}
public McpResourceDescriptor GetDescriptor()
{
return new McpResourceDescriptor
@@ -63,7 +53,7 @@ public class ProjectsGetResource : IMcpResource
McpResourceRequest request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var tenantId = tenantContext.GetCurrentTenantId();
// Extract {id} from URI parameters
if (!request.UriParams.TryGetValue("id", out var idString))
@@ -78,10 +68,10 @@ public class ProjectsGetResource : IMcpResource
var projectId = ProjectId.From(projectIdGuid);
_logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId}", projectId, tenantId);
logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId}", projectId, tenantId);
// Get project with full hierarchy (read-only)
var project = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
var project = await projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
if (project == null)
{
@@ -113,7 +103,7 @@ public class ProjectsGetResource : IMcpResource
var json = JsonSerializer.Serialize(projectDto, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId}", projectId, tenantId);
logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId}", projectId, tenantId);
return new McpResourceContent
{

View File

@@ -10,7 +10,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
/// Resource: colaflow://projects.list
/// Lists all projects in the current tenant
/// </summary>
public class ProjectsListResource : IMcpResource
public class ProjectsListResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<ProjectsListResource> logger)
: IMcpResource
{
public string Uri => "colaflow://projects.list";
public string Name => "Projects List";
@@ -19,20 +23,6 @@ public class ProjectsListResource : IMcpResource
public string Category => "Projects";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ProjectsListResource> _logger;
public ProjectsListResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<ProjectsListResource> logger)
{
_projectRepository = projectRepository;
_tenantContext = tenantContext;
_logger = logger;
}
public McpResourceDescriptor GetDescriptor()
{
return new McpResourceDescriptor
@@ -58,12 +48,12 @@ public class ProjectsListResource : IMcpResource
McpResourceRequest request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var tenantId = tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching projects list for tenant {TenantId}", tenantId);
logger.LogDebug("Fetching projects list for tenant {TenantId}", tenantId);
// Get all projects (read-only)
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
var projects = await projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
// Map to DTOs
var projectDtos = projects.Select(p => new
@@ -84,7 +74,7 @@ public class ProjectsListResource : IMcpResource
total = projectDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} projects for tenant {TenantId}", projectDtos.Count, tenantId);
logger.LogInformation("Retrieved {Count} projects for tenant {TenantId}", projectDtos.Count, tenantId);
return new McpResourceContent
{

View File

@@ -11,7 +11,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
/// Resource: colaflow://sprints.current
/// Gets the currently active Sprint(s)
/// </summary>
public class SprintsCurrentResource : IMcpResource
public class SprintsCurrentResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<SprintsCurrentResource> logger)
: IMcpResource
{
public string Uri => "colaflow://sprints.current";
public string Name => "Current Sprint";
@@ -20,34 +24,20 @@ public class SprintsCurrentResource : IMcpResource
public string Category => "Sprints";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SprintsCurrentResource> _logger;
public SprintsCurrentResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<SprintsCurrentResource> logger)
{
_projectRepository = projectRepository;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<McpResourceContent> GetContentAsync(
McpResourceRequest request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var tenantId = tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching active sprints for tenant {TenantId}", tenantId);
logger.LogDebug("Fetching active sprints for tenant {TenantId}", tenantId);
// Get active sprints
var activeSprints = await _projectRepository.GetActiveSprintsAsync(cancellationToken);
var activeSprints = await projectRepository.GetActiveSprintsAsync(cancellationToken);
if (activeSprints.Count == 0)
{
_logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
throw new McpNotFoundException("No active sprints found");
}
@@ -75,7 +65,7 @@ public class SprintsCurrentResource : IMcpResource
total = sprintDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId}",
logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId}",
sprintDtos.Count, tenantId);
return new McpResourceContent

View File

@@ -12,7 +12,11 @@ namespace ColaFlow.Modules.Mcp.Application.Resources;
/// Lists all team members in the current tenant
/// Query params: project (optional filter by project)
/// </summary>
public class UsersListResource : IMcpResource
public class UsersListResource(
IUserRepository userRepository,
ITenantContext tenantContext,
ILogger<UsersListResource> logger)
: IMcpResource
{
public string Uri => "colaflow://users.list";
public string Name => "Team Members";
@@ -21,30 +25,16 @@ public class UsersListResource : IMcpResource
public string Category => "Users";
public string Version => "1.0";
private readonly IUserRepository _userRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<UsersListResource> _logger;
public UsersListResource(
IUserRepository userRepository,
ITenantContext tenantContext,
ILogger<UsersListResource> logger)
{
_userRepository = userRepository;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<McpResourceContent> GetContentAsync(
McpResourceRequest request,
CancellationToken cancellationToken)
{
var tenantId = _tenantContext.GetCurrentTenantId();
var tenantId = tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching users list for tenant {TenantId}", tenantId);
logger.LogDebug("Fetching users list for tenant {TenantId}", tenantId);
// Get all users for tenant
var users = await _userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
var users = await userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
// Map to DTOs
var userDtos = users.Select(u => new
@@ -64,7 +54,7 @@ public class UsersListResource : IMcpResource
total = userDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} users for tenant {TenantId}", userDtos.Count, tenantId);
logger.LogInformation("Retrieved {Count} users for tenant {TenantId}", userDtos.Count, tenantId);
return new McpResourceContent
{

View File

@@ -0,0 +1,237 @@
using System.ComponentModel;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkPrompts;
/// <summary>
/// MCP Prompts for project management tasks
/// Provides pre-defined prompt templates for AI interactions
/// </summary>
[McpServerPromptType]
public static class ProjectManagementPrompts
{
[McpServerPrompt]
[Description("Generate a Product Requirements Document (PRD) for an Epic")]
public static ChatMessage GeneratePrdPrompt(
[Description("The Epic title")] string epicTitle,
[Description("Brief description of the Epic")] string epicDescription)
{
var promptText = $@"You are a Product Manager creating a Product Requirements Document (PRD).
**Epic**: {epicTitle}
**Description**: {epicDescription}
Please create a comprehensive PRD that includes:
1. **Executive Summary**
- Brief overview of the feature
- Business value and goals
2. **User Stories**
- Who are the users?
- What problems does this solve?
3. **Functional Requirements**
- List all key features
- User workflows and interactions
4. **Non-Functional Requirements**
- Performance expectations
- Security considerations
- Scalability needs
5. **Acceptance Criteria**
- Clear, testable criteria for completion
- Success metrics
6. **Technical Considerations**
- API requirements
- Data models
- Integration points
7. **Timeline and Milestones**
- Estimated timeline
- Key milestones
- Dependencies
Please format the PRD in Markdown.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Break down an Epic into smaller Stories")]
public static ChatMessage SplitEpicToStoriesPrompt(
[Description("The Epic title")] string epicTitle,
[Description("The Epic description or PRD")] string epicContent)
{
var promptText = $@"You are a Product Manager breaking down an Epic into manageable Stories.
**Epic**: {epicTitle}
**Epic Content**:
{epicContent}
Please break this Epic down into 5-10 User Stories following these guidelines:
1. **Each Story should**:
- Be independently valuable
- Be completable in 1-3 days
- Follow the format: ""As a [user], I want [feature] so that [benefit]""
- Include acceptance criteria
2. **Story Structure**:
```
**Story Title**: [Concise title]
**User Story**: As a [user], I want [feature] so that [benefit]
**Description**: [Detailed description]
**Acceptance Criteria**:
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
**Estimated Effort**: [Small/Medium/Large]
**Priority**: [High/Medium/Low]
```
3. **Prioritize the Stories**:
- Mark dependencies between stories
- Suggest implementation order
Please output the Stories in Markdown format.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Generate acceptance criteria for a Story")]
public static ChatMessage GenerateAcceptanceCriteriaPrompt(
[Description("The Story title")] string storyTitle,
[Description("The Story description")] string storyDescription)
{
var promptText = $@"You are a QA Engineer defining acceptance criteria for a User Story.
**Story**: {storyTitle}
**Description**: {storyDescription}
Please create comprehensive acceptance criteria following these guidelines:
1. **Criteria should be**:
- Specific and measurable
- Testable (can be verified)
- Clear and unambiguous
- Focused on outcomes, not implementation
2. **Include**:
- Functional acceptance criteria (what the feature does)
- Non-functional acceptance criteria (performance, security, UX)
- Edge cases and error scenarios
3. **Format**:
```
**Given**: [Initial context/state]
**When**: [Action taken]
**Then**: [Expected outcome]
```
Please output 5-10 acceptance criteria in Markdown format.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Analyze Sprint progress and provide insights")]
public static ChatMessage AnalyzeSprintProgressPrompt(
[Description("Sprint name")] string sprintName,
[Description("Sprint data (JSON format)")] string sprintData)
{
var promptText = $@"You are a Scrum Master analyzing Sprint progress.
**Sprint**: {sprintName}
**Sprint Data**:
```json
{sprintData}
```
Please analyze the Sprint and provide:
1. **Progress Summary**:
- Overall completion percentage
- Story points completed vs. planned
- Burndown trend analysis
2. **Risk Assessment**:
- Tasks at risk of not completing
- Blockers and bottlenecks
- Velocity concerns
3. **Recommendations**:
- Actions to get back on track
- Tasks that could be descoped
- Resource allocation suggestions
4. **Team Health**:
- Workload distribution
- Identify overloaded team members
- Suggest load balancing
Please format the analysis in Markdown with clear sections.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Generate a Sprint retrospective summary")]
public static ChatMessage GenerateRetrospectivePrompt(
[Description("Sprint name")] string sprintName,
[Description("Sprint completion data")] string sprintData,
[Description("Team feedback (optional)")] string? teamFeedback = null)
{
var feedbackSection = string.IsNullOrEmpty(teamFeedback)
? ""
: $@"
**Team Feedback**:
{teamFeedback}";
var promptText = $@"You are a Scrum Master facilitating a Sprint Retrospective.
**Sprint**: {sprintName}
**Sprint Data**:
```json
{sprintData}
```
{feedbackSection}
Please create a comprehensive retrospective summary using the format:
1. **What Went Well** 🎉
- Successes and achievements
- Team highlights
2. **What Didn't Go Well** 😞
- Challenges faced
- Missed goals
- Technical issues
3. **Lessons Learned** 📚
- Key takeaways
- Insights gained
4. **Action Items** 🎯
- Specific, actionable improvements
- Owner for each action
- Target date
5. **Metrics** 📊
- Velocity achieved
- Story points completed
- Sprint goal achievement
Please format the retrospective in Markdown.";
return new ChatMessage(ChatRole.User, promptText);
}
}

View File

@@ -0,0 +1,206 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Issues (SDK-based implementation)
/// Provides search and get functionality for Issues (Epics, Stories, Tasks)
/// </summary>
[McpServerResourceType]
public class IssuesSdkResource
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<IssuesSdkResource> _logger;
public IssuesSdkResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<IssuesSdkResource> logger)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("Search issues with filters (status, priority, assignee, type)")]
public async Task<string> SearchIssuesAsync(
[Description("Filter by project ID (optional)")] Guid? project = null,
[Description("Filter by status (optional)")] string? status = null,
[Description("Filter by priority (optional)")] string? priority = null,
[Description("Filter by type: epic, story, or task (optional)")] string? type = null,
[Description("Filter by assignee ID (optional)")] Guid? assignee = null,
[Description("Maximum number of results (default: 100)")] int limit = 100,
[Description("Offset for pagination (default: 0)")] int offset = 0,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Searching issues for tenant {TenantId} (SDK)", tenantId);
// Limit max results
limit = Math.Min(limit, 100);
// Get projects
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
// Filter by project if specified
if (project.HasValue)
{
var projectId = ProjectId.From(project.Value);
var singleProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
projects = singleProject != null ? new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project> { singleProject } : new();
}
else
{
// Load full hierarchy for all projects
var projectsWithHierarchy = new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project>();
foreach (var p in projects)
{
var fullProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
if (fullProject != null)
{
projectsWithHierarchy.Add(fullProject);
}
}
projects = projectsWithHierarchy;
}
// Collect all issues
var allIssues = new List<object>();
foreach (var proj in projects)
{
if (proj.Epics == null) continue;
foreach (var epic in proj.Epics)
{
// Filter Epics
if (ShouldIncludeIssue("epic", type, epic.Status.ToString(), status,
epic.Priority.ToString(), priority, null, assignee?.ToString()))
{
allIssues.Add(new
{
id = epic.Id.Value,
type = "Epic",
name = epic.Name,
description = epic.Description,
status = epic.Status.ToString(),
priority = epic.Priority.ToString(),
projectId = proj.Id.Value,
projectName = proj.Name,
createdAt = epic.CreatedAt,
storyCount = epic.Stories?.Count ?? 0
});
}
// Filter Stories
if (epic.Stories != null)
{
foreach (var story in epic.Stories)
{
if (ShouldIncludeIssue("story", type, story.Status.ToString(), status,
story.Priority.ToString(), priority, story.AssigneeId?.Value.ToString(), assignee?.ToString()))
{
allIssues.Add(new
{
id = story.Id.Value,
type = "Story",
title = story.Title,
description = story.Description,
status = story.Status.ToString(),
priority = story.Priority.ToString(),
assigneeId = story.AssigneeId?.Value,
projectId = proj.Id.Value,
projectName = proj.Name,
epicId = epic.Id.Value,
epicName = epic.Name,
createdAt = story.CreatedAt,
taskCount = story.Tasks?.Count ?? 0
});
}
// Filter Tasks
if (story.Tasks != null)
{
foreach (var task in story.Tasks)
{
if (ShouldIncludeIssue("task", type, task.Status.ToString(), status,
task.Priority.ToString(), priority, task.AssigneeId?.Value.ToString(), assignee?.ToString()))
{
allIssues.Add(new
{
id = task.Id.Value,
type = "Task",
title = task.Title,
description = task.Description,
status = task.Status.ToString(),
priority = task.Priority.ToString(),
assigneeId = task.AssigneeId?.Value,
projectId = proj.Id.Value,
projectName = proj.Name,
storyId = story.Id.Value,
storyTitle = story.Title,
epicId = epic.Id.Value,
epicName = epic.Name,
createdAt = task.CreatedAt
});
}
}
}
}
}
}
}
// Apply pagination
var total = allIssues.Count;
var paginatedIssues = allIssues.Skip(offset).Take(limit).ToList();
var result = JsonSerializer.Serialize(new
{
issues = paginatedIssues,
total = total,
limit = limit,
offset = offset
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Found {Count} issues for tenant {TenantId} (SDK, total: {Total})",
paginatedIssues.Count, tenantId, total);
return result;
}
private bool ShouldIncludeIssue(
string issueType,
string? typeFilter,
string status,
string? statusFilter,
string priority,
string? priorityFilter,
string? assigneeId,
string? assigneeFilter)
{
if (!string.IsNullOrEmpty(typeFilter) && !issueType.Equals(typeFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(statusFilter) && !status.Equals(statusFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(priorityFilter) && !priority.Equals(priorityFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(assigneeFilter) && assigneeId != assigneeFilter)
return false;
return true;
}
}

View File

@@ -0,0 +1,106 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Projects (SDK-based implementation)
/// Provides access to project data in the current tenant
/// </summary>
[McpServerResourceType]
public class ProjectsSdkResource
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ProjectsSdkResource> _logger;
public ProjectsSdkResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<ProjectsSdkResource> logger)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("List all projects in current tenant")]
public async Task<string> ListProjectsAsync(CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching projects list for tenant {TenantId} (SDK)", tenantId);
// Get all projects (read-only)
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
// Map to DTOs
var projectDtos = projects.Select(p => new
{
id = p.Id.Value,
name = p.Name,
key = p.Key.ToString(),
description = p.Description,
status = p.Status.ToString(),
createdAt = p.CreatedAt,
updatedAt = p.UpdatedAt,
epicCount = p.Epics?.Count ?? 0
}).ToList();
var result = JsonSerializer.Serialize(new
{
projects = projectDtos,
total = projectDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} projects for tenant {TenantId} (SDK)", projectDtos.Count, tenantId);
return result;
}
[McpServerResource]
[Description("Get detailed information about a specific project")]
public async Task<string> GetProjectAsync(
[Description("The project ID")] Guid projectId,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId} (SDK)", projectId, tenantId);
var project = await _projectRepository.GetByIdAsync(
ProjectManagement.Domain.ValueObjects.ProjectId.From(projectId),
cancellationToken);
if (project == null)
{
throw new InvalidOperationException($"Project with ID {projectId} not found");
}
var result = JsonSerializer.Serialize(new
{
id = project.Id.Value,
name = project.Name,
key = project.Key.ToString(),
description = project.Description,
status = project.Status.ToString(),
createdAt = project.CreatedAt,
updatedAt = project.UpdatedAt,
epics = project.Epics?.Select(e => new
{
id = e.Id.Value,
title = e.Name, // Epic uses Name instead of Title
status = e.Status.ToString()
}).ToList() ?? (object)new List<object>()
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId} (SDK)", projectId, tenantId);
return result;
}
}

View File

@@ -0,0 +1,76 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Sprints (SDK-based implementation)
/// Provides access to Sprint data
/// </summary>
[McpServerResourceType]
public class SprintsSdkResource
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SprintsSdkResource> _logger;
public SprintsSdkResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<SprintsSdkResource> logger)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("Get the currently active Sprint(s)")]
public async Task<string> GetCurrentSprintAsync(CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching active sprints for tenant {TenantId} (SDK)", tenantId);
// Get active sprints
var activeSprints = await _projectRepository.GetActiveSprintsAsync(cancellationToken);
if (activeSprints.Count == 0)
{
_logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
throw new McpNotFoundException("No active sprints found");
}
// Map to DTOs with statistics
var sprintDtos = activeSprints.Select(sprint => new
{
id = sprint.Id.Value,
name = sprint.Name,
goal = sprint.Goal,
status = sprint.Status.ToString(),
startDate = sprint.StartDate,
endDate = sprint.EndDate,
createdAt = sprint.CreatedAt,
statistics = new
{
totalTasks = sprint.TaskIds?.Count ?? 0
}
}).ToList();
var result = JsonSerializer.Serialize(new
{
sprints = sprintDtos,
total = sprintDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId} (SDK)",
sprintDtos.Count, tenantId);
return result;
}
}

View File

@@ -0,0 +1,65 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Users (SDK-based implementation)
/// Provides access to team member data
/// </summary>
[McpServerResourceType]
public class UsersSdkResource
{
private readonly IUserRepository _userRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<UsersSdkResource> _logger;
public UsersSdkResource(
IUserRepository userRepository,
ITenantContext tenantContext,
ILogger<UsersSdkResource> logger)
{
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("List all team members in current tenant")]
public async Task<string> ListUsersAsync(CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching users list for tenant {TenantId} (SDK)", tenantId);
// Get all users for tenant
var users = await _userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
// Map to DTOs
var userDtos = users.Select(u => new
{
id = u.Id,
email = u.Email.Value,
fullName = u.FullName.ToString(),
status = u.Status.ToString(),
createdAt = u.CreatedAt,
avatarUrl = u.AvatarUrl,
jobTitle = u.JobTitle
}).ToList();
var result = JsonSerializer.Serialize(new
{
users = userDtos,
total = userDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} users for tenant {TenantId} (SDK)", userDtos.Count, tenantId);
return result;
}
}

View File

@@ -0,0 +1,117 @@
using System.ComponentModel;
using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Domain.Services;
using ColaFlow.Modules.IssueManagement.Domain.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: add_comment (SDK-based implementation)
/// Adds a comment to an existing Issue
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class AddCommentSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IIssueRepository _issueRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<AddCommentSdkTool> _logger;
public AddCommentSdkTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
IHttpContextAccessor httpContextAccessor,
DiffPreviewService diffPreviewService,
ILogger<AddCommentSdkTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerTool]
[Description("Add a comment to an existing issue. Supports markdown formatting. Requires human approval before being added.")]
public async Task<string> AddCommentAsync(
[Description("The ID of the issue to comment on")] Guid issueId,
[Description("The comment content (supports markdown, max 2000 characters)")] string content,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing add_comment tool (SDK)");
// 1. Validate content
if (string.IsNullOrWhiteSpace(content))
throw new McpInvalidParamsException("Comment content cannot be empty");
if (content.Length > 2000)
throw new McpInvalidParamsException("Comment content cannot exceed 2000 characters");
// 2. Verify issue exists
var issue = await _issueRepository.GetByIdAsync(issueId, cancellationToken);
if (issue == null)
throw new McpNotFoundException("Issue", issueId.ToString());
// 3. Get API Key ID (to track who created the comment)
var apiKeyId = _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?;
// 4. Build comment data for diff preview
var commentData = new
{
issueId = issueId,
content = content,
authorType = "AI",
authorId = apiKeyId,
createdAt = DateTime.UtcNow
};
// 5. Generate Diff Preview (CREATE Comment operation)
var diff = _diffPreviewService.GenerateCreateDiff(
entityType: "Comment",
afterEntity: commentData,
entityKey: $"Comment on {issue.Type}-{issue.Id.ToString().Substring(0, 8)}"
);
// 6. Create PendingChange
var pendingChange = await _pendingChangeService.CreateAsync(
new CreatePendingChangeRequest
{
ToolName = "add_comment",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - CREATE Comment on Issue {IssueId}",
pendingChange.Id, issueId);
// 7. Return pendingChangeId to AI
return $"Comment creation request submitted for approval.\n\n" +
$"**Pending Change ID**: {pendingChange.Id}\n" +
$"**Status**: Pending Approval\n" +
$"**Issue**: {issue.Title}\n" +
$"**Comment Preview**: {(content.Length > 100 ? content.Substring(0, 100) + "..." : content)}\n\n" +
$"A human user must approve this change before the comment is added. " +
$"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing add_comment tool (SDK)");
throw new McpInvalidParamsException($"Error adding comment: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,139 @@
using System.ComponentModel;
using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Application.Tools.Validation;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Domain.Services;
using ColaFlow.Modules.IssueManagement.Domain.Enums;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: create_issue (SDK-based implementation)
/// Creates a new Issue (Epic, Story, Task, or Bug)
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class CreateIssueSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<CreateIssueSdkTool> _logger;
public CreateIssueSdkTool(
IPendingChangeService pendingChangeService,
IProjectRepository projectRepository,
ITenantContext tenantContext,
DiffPreviewService diffPreviewService,
ILogger<CreateIssueSdkTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerTool]
[Description("Create a new issue (Epic, Story, Task, or Bug) in a ColaFlow project. The issue will be created in 'Backlog' status and requires human approval before being created.")]
public async Task<string> CreateIssueAsync(
[Description("The ID of the project to create the issue in")] Guid projectId,
[Description("Issue title (max 200 characters)")] string title,
[Description("Issue type: Epic, Story, Task, or Bug")] string type,
[Description("Detailed issue description (optional, max 2000 characters)")] string? description = null,
[Description("Issue priority: Low, Medium, High, or Critical (defaults to Medium)")] string? priority = null,
[Description("User ID to assign the issue to (optional)")] Guid? assigneeId = null,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing create_issue tool (SDK)");
// 1. Validate input
if (string.IsNullOrWhiteSpace(title))
throw new McpInvalidParamsException("Issue title cannot be empty");
if (title.Length > 200)
throw new McpInvalidParamsException("Issue title cannot exceed 200 characters");
if (description?.Length > 2000)
throw new McpInvalidParamsException("Issue description cannot exceed 2000 characters");
// Parse enums
if (!Enum.TryParse<IssueType>(type, ignoreCase: true, out var issueType))
throw new McpInvalidParamsException($"Invalid issue type: {type}. Must be Epic, Story, Task, or Bug");
var issuePriority = IssuePriority.Medium;
if (!string.IsNullOrEmpty(priority))
{
if (!Enum.TryParse<IssuePriority>(priority, ignoreCase: true, out issuePriority))
throw new McpInvalidParamsException($"Invalid priority: {priority}. Must be Low, Medium, High, or Critical");
}
// 2. Verify project exists
var project = await _projectRepository.GetByIdAsync(ProjectId.From(projectId), cancellationToken);
if (project == null)
throw new McpNotFoundException("Project", projectId.ToString());
// 3. Build "after data" object for diff preview
var afterData = new
{
projectId = projectId,
title = title,
description = description ?? string.Empty,
type = issueType.ToString(),
priority = issuePriority.ToString(),
status = IssueStatus.Backlog.ToString(), // Default status
assigneeId = assigneeId
};
// 4. Generate Diff Preview (CREATE operation)
var diff = _diffPreviewService.GenerateCreateDiff(
entityType: "Issue",
afterEntity: afterData,
entityKey: null // No key yet (will be generated on approval)
);
// 5. Create PendingChange (do NOT execute yet)
var pendingChange = await _pendingChangeService.CreateAsync(
new CreatePendingChangeRequest
{
ToolName = "create_issue",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - CREATE Issue: {Title}",
pendingChange.Id, title);
// 6. Return pendingChangeId to AI (NOT the created issue)
return $"Issue creation request submitted for approval.\n\n" +
$"**Pending Change ID**: {pendingChange.Id}\n" +
$"**Status**: Pending Approval\n" +
$"**Issue Type**: {issueType}\n" +
$"**Title**: {title}\n" +
$"**Priority**: {issuePriority}\n" +
$"**Project**: {project.Name}\n\n" +
$"A human user must approve this change before the issue is created. " +
$"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing create_issue tool (SDK)");
throw new McpInvalidParamsException($"Error creating issue: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,167 @@
using System.ComponentModel;
using System.Security.Claims;
using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Domain.Services;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: create_project (SDK-based implementation)
/// Creates a new Project in ColaFlow
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class CreateProjectSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<CreateProjectSdkTool> _logger;
public CreateProjectSdkTool(
IPendingChangeService pendingChangeService,
IProjectRepository projectRepository,
ITenantContext tenantContext,
IHttpContextAccessor httpContextAccessor,
DiffPreviewService diffPreviewService,
ILogger<CreateProjectSdkTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerTool]
[Description("Create a new project in ColaFlow. Projects organize issues (Epics, Stories, Tasks, Bugs). Requires human approval before being created.")]
public async Task<string> CreateProjectAsync(
[Description("The name of the project (max 100 characters)")] string name,
[Description("The project key (e.g., 'CFD', 'PRJ'). Must be 2-10 uppercase letters and unique within the tenant.")] string key,
[Description("Detailed project description (optional, max 500 characters)")] string? description = null,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing create_project tool (SDK)");
// 1. Validate input
if (string.IsNullOrWhiteSpace(name))
throw new McpInvalidParamsException("Project name cannot be empty");
if (name.Length > 100)
throw new McpInvalidParamsException("Project name cannot exceed 100 characters");
if (string.IsNullOrWhiteSpace(key))
throw new McpInvalidParamsException("Project key cannot be empty");
if (key.Length < 2 || key.Length > 10)
throw new McpInvalidParamsException("Project key must be between 2 and 10 characters");
if (!System.Text.RegularExpressions.Regex.IsMatch(key, "^[A-Z]+$"))
throw new McpInvalidParamsException("Project key must contain only uppercase letters");
if (description?.Length > 500)
throw new McpInvalidParamsException("Project description cannot exceed 500 characters");
// 2. Check if project key already exists
var existingProject = await _projectRepository.GetByKeyAsync(key, cancellationToken);
if (existingProject != null)
throw new McpInvalidParamsException($"Project with key '{key}' already exists");
// 3. Get Owner ID from HTTP context claims
var ownerId = GetUserIdFromClaims();
// 4. Get Tenant ID from context
var tenantId = _tenantContext.GetCurrentTenantId();
// 5. Build "after data" object for diff preview
var afterData = new
{
name = name,
key = key,
description = description ?? string.Empty,
ownerId = ownerId,
tenantId = tenantId,
status = "Active"
};
// 6. Generate Diff Preview (CREATE operation)
var diff = _diffPreviewService.GenerateCreateDiff(
entityType: "Project",
afterEntity: afterData,
entityKey: key // Use project key as the entity key
);
// 7. Create PendingChange (do NOT execute yet)
var pendingChange = await _pendingChangeService.CreateAsync(
new CreatePendingChangeRequest
{
ToolName = "create_project",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - CREATE Project: {Name} ({Key})",
pendingChange.Id, name, key);
// 8. Return pendingChangeId to AI (NOT the created project)
return $"Project creation request submitted for approval.\n\n" +
$"**Pending Change ID**: {pendingChange.Id}\n" +
$"**Status**: Pending Approval\n" +
$"**Project Name**: {name}\n" +
$"**Project Key**: {key}\n" +
$"**Description**: {(string.IsNullOrEmpty(description) ? "(none)" : description)}\n\n" +
$"A human user must approve this change before the project is created. " +
$"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing create_project tool (SDK)");
throw new McpInvalidParamsException($"Error creating project: {ex.Message}");
}
}
private Guid GetUserIdFromClaims()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
throw new McpInvalidParamsException("HTTP context not available");
var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
// Fallback: Try to get from API key context
var apiKeyId = httpContext.Items["ApiKeyId"] as Guid?;
if (apiKeyId.HasValue)
{
// Use API key ID as owner ID (for MCP API key authentication)
return apiKeyId.Value;
}
throw new McpInvalidParamsException("User ID not found in authentication context");
}
if (!Guid.TryParse(userIdClaim, out var userId))
throw new McpInvalidParamsException("Invalid user ID format in authentication context");
return userId;
}
}

View File

@@ -0,0 +1,122 @@
using System.ComponentModel;
using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Domain.Services;
using ColaFlow.Modules.IssueManagement.Domain.Enums;
using ColaFlow.Modules.IssueManagement.Domain.Repositories;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: update_status (SDK-based implementation)
/// Updates the status of an existing Issue
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class UpdateStatusSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IIssueRepository _issueRepository;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<UpdateStatusSdkTool> _logger;
public UpdateStatusSdkTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
DiffPreviewService diffPreviewService,
ILogger<UpdateStatusSdkTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerTool]
[Description("Update the status of an existing issue. Supports workflow transitions (Backlog → Todo → InProgress → Done). Requires human approval before being applied.")]
public async Task<string> UpdateStatusAsync(
[Description("The ID of the issue to update")] Guid issueId,
[Description("The new status: Backlog, Todo, InProgress, or Done")] string newStatus,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing update_status tool (SDK)");
// 1. Validate and parse status
if (!Enum.TryParse<IssueStatus>(newStatus, ignoreCase: true, out var statusEnum))
throw new McpInvalidParamsException($"Invalid status: {newStatus}. Must be Backlog, Todo, InProgress, or Done");
// 2. Fetch current issue
var issue = await _issueRepository.GetByIdAsync(issueId, cancellationToken);
if (issue == null)
throw new McpNotFoundException("Issue", issueId.ToString());
var oldStatus = issue.Status;
// 3. Build before and after data for diff preview
var beforeData = new
{
id = issue.Id,
title = issue.Title,
type = issue.Type.ToString(),
status = oldStatus.ToString(),
priority = issue.Priority.ToString()
};
var afterData = new
{
id = issue.Id,
title = issue.Title,
type = issue.Type.ToString(),
status = statusEnum.ToString(), // Only status changed
priority = issue.Priority.ToString()
};
// 4. Generate Diff Preview (UPDATE operation)
var diff = _diffPreviewService.GenerateUpdateDiff(
entityType: "Issue",
entityId: issueId,
beforeEntity: beforeData,
afterEntity: afterData,
entityKey: $"{issue.Type}-{issue.Id.ToString().Substring(0, 8)}"
);
// 5. Create PendingChange
var pendingChange = await _pendingChangeService.CreateAsync(
new CreatePendingChangeRequest
{
ToolName = "update_status",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - UPDATE Issue {IssueId} status: {OldStatus} → {NewStatus}",
pendingChange.Id, issueId, oldStatus, statusEnum);
// 6. Return pendingChangeId to AI
return $"Issue status update request submitted for approval.\n\n" +
$"**Pending Change ID**: {pendingChange.Id}\n" +
$"**Status**: Pending Approval\n" +
$"**Issue**: {issue.Title}\n" +
$"**Old Status**: {oldStatus}\n" +
$"**New Status**: {statusEnum}\n\n" +
$"A human user must approve this change before the issue status is updated. " +
$"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing update_status tool (SDK)");
throw new McpInvalidParamsException($"Error updating issue status: {ex.Message}");
}
}
}

View File

@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
/// <summary>
/// Service implementation for MCP API Key management
/// </summary>
public class McpApiKeyService : IMcpApiKeyService
public class McpApiKeyService(
IMcpApiKeyRepository repository,
ILogger<McpApiKeyService> logger)
: IMcpApiKeyService
{
private readonly IMcpApiKeyRepository _repository;
private readonly ILogger<McpApiKeyService> _logger;
public McpApiKeyService(
IMcpApiKeyRepository repository,
ILogger<McpApiKeyService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IMcpApiKeyRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
private readonly ILogger<McpApiKeyService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task<CreateApiKeyResponse> CreateAsync(CreateApiKeyRequest request, CancellationToken cancellationToken = default)
{

View File

@@ -8,32 +8,26 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
/// Implementation of MCP Resource Registry
/// Enhanced with category support and dynamic registration
/// </summary>
public class McpResourceRegistry : IMcpResourceRegistry
public class McpResourceRegistry(ILogger<McpResourceRegistry> logger) : IMcpResourceRegistry
{
private readonly ILogger<McpResourceRegistry> _logger;
private readonly Dictionary<string, IMcpResource> _resources = new();
private readonly List<IMcpResource> _resourceList = new();
private readonly object _lock = new();
public McpResourceRegistry(ILogger<McpResourceRegistry> logger)
{
_logger = logger;
}
public void RegisterResource(IMcpResource resource)
{
lock (_lock)
{
if (_resources.ContainsKey(resource.Uri))
{
_logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
_resourceList.Remove(_resources[resource.Uri]);
}
_resources[resource.Uri] = resource;
_resourceList.Add(resource);
_logger.LogInformation("Registered MCP Resource: {Uri} - {Name} [{Category}]",
logger.LogInformation("Registered MCP Resource: {Uri} - {Name} [{Category}]",
resource.Uri, resource.Name, resource.Category);
}
}

View File

@@ -13,27 +13,19 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
/// <summary>
/// Service implementation for PendingChange management
/// </summary>
public class PendingChangeService : IPendingChangeService
public class PendingChangeService(
IPendingChangeRepository repository,
ITenantContext tenantContext,
IHttpContextAccessor httpContextAccessor,
IPublisher publisher,
ILogger<PendingChangeService> logger)
: IPendingChangeService
{
private readonly IPendingChangeRepository _repository;
private readonly ITenantContext _tenantContext;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IPublisher _publisher;
private readonly ILogger<PendingChangeService> _logger;
public PendingChangeService(
IPendingChangeRepository repository,
ITenantContext tenantContext,
IHttpContextAccessor httpContextAccessor,
IPublisher publisher,
ILogger<PendingChangeService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IPendingChangeRepository _repository = repository ?? throw new ArgumentNullException(nameof(repository));
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
private readonly IPublisher _publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
private readonly ILogger<PendingChangeService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task<PendingChangeDto> CreateAsync(
CreatePendingChangeRequest request,
@@ -42,7 +34,9 @@ public class PendingChangeService : IPendingChangeService
var tenantId = _tenantContext.GetCurrentTenantId();
// Get API Key ID from HttpContext (set by MCP authentication middleware)
var apiKeyIdNullable = _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?;
// Check both "McpApiKeyId" (from McpApiKeyAuthenticationHandler) and "ApiKeyId" (from legacy middleware)
var apiKeyIdNullable = _httpContextAccessor.HttpContext?.Items["McpApiKeyId"] as Guid?
?? _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?;
if (!apiKeyIdNullable.HasValue)
{
throw new McpUnauthorizedException("API Key not found in request context");

View File

@@ -9,18 +9,11 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
/// Implementation of Resource Discovery Service
/// Scans assemblies to find all IMcpResource implementations
/// </summary>
public class ResourceDiscoveryService : IResourceDiscoveryService
public class ResourceDiscoveryService(ILogger<ResourceDiscoveryService> logger) : IResourceDiscoveryService
{
private readonly ILogger<ResourceDiscoveryService> _logger;
public ResourceDiscoveryService(ILogger<ResourceDiscoveryService> logger)
{
_logger = logger;
}
public IReadOnlyList<Type> DiscoverResourceTypes()
{
_logger.LogInformation("Starting MCP Resource discovery via Assembly scanning...");
logger.LogInformation("Starting MCP Resource discovery via Assembly scanning...");
var resourceTypes = new List<Type>();
@@ -29,7 +22,7 @@ public class ResourceDiscoveryService : IResourceDiscoveryService
.Where(a => !a.IsDynamic && a.FullName != null && a.FullName.StartsWith("ColaFlow"))
.ToList();
_logger.LogDebug("Scanning {Count} assemblies for IMcpResource implementations", assemblies.Count);
logger.LogDebug("Scanning {Count} assemblies for IMcpResource implementations", assemblies.Count);
foreach (var assembly in assemblies)
{
@@ -44,18 +37,18 @@ public class ResourceDiscoveryService : IResourceDiscoveryService
if (types.Any())
{
_logger.LogDebug("Found {Count} resources in assembly {Assembly}",
logger.LogDebug("Found {Count} resources in assembly {Assembly}",
types.Count, assembly.GetName().Name);
resourceTypes.AddRange(types);
}
}
catch (ReflectionTypeLoadException ex)
{
_logger.LogWarning(ex, "Failed to load types from assembly {Assembly}", assembly.FullName);
logger.LogWarning(ex, "Failed to load types from assembly {Assembly}", assembly.FullName);
}
}
_logger.LogInformation("Discovered {Count} MCP Resource types", resourceTypes.Count);
logger.LogInformation("Discovered {Count} MCP Resource types", resourceTypes.Count);
return resourceTypes.AsReadOnly();
}
@@ -75,17 +68,17 @@ public class ResourceDiscoveryService : IResourceDiscoveryService
if (resource != null)
{
resources.Add(resource);
_logger.LogDebug("Instantiated resource: {ResourceType} -> {Uri}",
logger.LogDebug("Instantiated resource: {ResourceType} -> {Uri}",
resourceType.Name, resource.Uri);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to instantiate resource type {ResourceType}", resourceType.FullName);
logger.LogError(ex, "Failed to instantiate resource type {ResourceType}", resourceType.FullName);
}
}
_logger.LogInformation("Instantiated {Count} MCP Resources", resources.Count);
logger.LogInformation("Instantiated {Count} MCP Resources", resources.Count);
return resources.AsReadOnly();
}

View File

@@ -16,13 +16,19 @@ namespace ColaFlow.Modules.Mcp.Application.Tools;
/// Adds a comment to an existing Issue
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
public class AddCommentTool : IMcpTool
public class AddCommentTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
IHttpContextAccessor httpContextAccessor,
DiffPreviewService diffPreviewService,
ILogger<AddCommentTool> logger)
: IMcpTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IIssueRepository _issueRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<AddCommentTool> _logger;
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
private readonly IIssueRepository _issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
private readonly DiffPreviewService _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
private readonly ILogger<AddCommentTool> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public string Name => "add_comment";
@@ -52,20 +58,6 @@ public class AddCommentTool : IMcpTool
Required = new List<string> { "issueId", "content" }
};
public AddCommentTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
IHttpContextAccessor httpContextAccessor,
DiffPreviewService diffPreviewService,
ILogger<AddCommentTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<McpToolResult> ExecuteAsync(
McpToolCall toolCall,
CancellationToken cancellationToken)

View File

@@ -18,13 +18,19 @@ namespace ColaFlow.Modules.Mcp.Application.Tools;
/// Creates a new Issue (Epic, Story, Task, or Bug)
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
public class CreateIssueTool : IMcpTool
public class CreateIssueTool(
IPendingChangeService pendingChangeService,
IProjectRepository projectRepository,
ITenantContext tenantContext,
DiffPreviewService diffPreviewService,
ILogger<CreateIssueTool> logger)
: IMcpTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<CreateIssueTool> _logger;
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
private readonly IProjectRepository _projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
private readonly ITenantContext _tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
private readonly DiffPreviewService _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
private readonly ILogger<CreateIssueTool> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public string Name => "create_issue";
@@ -77,20 +83,6 @@ public class CreateIssueTool : IMcpTool
Required = new List<string> { "projectId", "title", "type" }
};
public CreateIssueTool(
IPendingChangeService pendingChangeService,
IProjectRepository projectRepository,
ITenantContext tenantContext,
DiffPreviewService diffPreviewService,
ILogger<CreateIssueTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<McpToolResult> ExecuteAsync(
McpToolCall toolCall,
CancellationToken cancellationToken)

View File

@@ -16,12 +16,17 @@ namespace ColaFlow.Modules.Mcp.Application.Tools;
/// Updates the status of an existing Issue
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
public class UpdateStatusTool : IMcpTool
public class UpdateStatusTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
DiffPreviewService diffPreviewService,
ILogger<UpdateStatusTool> logger)
: IMcpTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IIssueRepository _issueRepository;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<UpdateStatusTool> _logger;
private readonly IPendingChangeService _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
private readonly IIssueRepository _issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
private readonly DiffPreviewService _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
private readonly ILogger<UpdateStatusTool> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public string Name => "update_status";
@@ -50,18 +55,6 @@ public class UpdateStatusTool : IMcpTool
Required = new List<string> { "issueId", "newStatus" }
};
public UpdateStatusTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
DiffPreviewService diffPreviewService,
ILogger<UpdateStatusTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<McpToolResult> ExecuteAsync(
McpToolCall toolCall,
CancellationToken cancellationToken)

View File

@@ -6,14 +6,9 @@ namespace ColaFlow.Modules.Mcp.Domain.Services;
/// <summary>
/// Domain service for managing task locks and concurrency control
/// </summary>
public sealed class TaskLockService
public sealed class TaskLockService(ITaskLockRepository taskLockRepository)
{
private readonly ITaskLockRepository _taskLockRepository;
public TaskLockService(ITaskLockRepository taskLockRepository)
{
_taskLockRepository = taskLockRepository ?? throw new ArgumentNullException(nameof(taskLockRepository));
}
private readonly ITaskLockRepository _taskLockRepository = taskLockRepository ?? throw new ArgumentNullException(nameof(taskLockRepository));
/// <summary>
/// Try to acquire a lock for a resource

View File

@@ -28,9 +28,9 @@ public sealed class ApiKeyPermissions
public List<string> AllowedTools { get; set; } = new();
/// <summary>
/// Private constructor for EF Core
/// Parameterless constructor for EF Core and JSON deserialization
/// </summary>
private ApiKeyPermissions()
public ApiKeyPermissions()
{
}

View File

@@ -71,18 +71,11 @@ public class McpAuditStatistics
/// <summary>
/// Implementation of MCP security audit logger
/// </summary>
public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
public class McpSecurityAuditLogger(ILogger<McpSecurityAuditLogger> logger) : IMcpSecurityAuditLogger
{
private readonly ILogger<McpSecurityAuditLogger> _logger;
private readonly McpAuditStatistics _statistics;
private readonly McpAuditStatistics _statistics = new();
private readonly object _statsLock = new();
public McpSecurityAuditLogger(ILogger<McpSecurityAuditLogger> logger)
{
_logger = logger;
_statistics = new McpAuditStatistics();
}
/// <summary>
/// Log successful MCP operation
/// </summary>
@@ -94,7 +87,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
_statistics.SuccessfulOperations++;
}
_logger.LogInformation(
logger.LogInformation(
"MCP Operation SUCCESS | Tenant: {TenantId} | User: {UserId} | Operation: {Operation} | Resource: {ResourceType}/{ResourceId}",
auditEvent.TenantId,
auditEvent.UserId,
@@ -115,7 +108,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
_statistics.AuthenticationFailures++;
}
_logger.LogWarning(
logger.LogWarning(
"MCP Authentication FAILURE | IP: {IpAddress} | Reason: {ErrorMessage}",
auditEvent.IpAddress,
auditEvent.ErrorMessage);
@@ -134,7 +127,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
_statistics.LastCrossTenantAttempt = DateTime.UtcNow;
}
_logger.LogCritical(
logger.LogCritical(
"SECURITY ALERT: Cross-Tenant Access Attempt! | Attacker Tenant: {TenantId} | Target Tenant: {TargetTenantId} | " +
"User: {UserId} | Resource: {ResourceType}/{ResourceId} | IP: {IpAddress}",
auditEvent.TenantId,
@@ -160,7 +153,7 @@ public class McpSecurityAuditLogger : IMcpSecurityAuditLogger
_statistics.AuthorizationFailures++;
}
_logger.LogWarning(
logger.LogWarning(
"MCP Authorization FAILURE | Tenant: {TenantId} | User: {UserId} | Operation: {Operation} | " +
"Resource: {ResourceType}/{ResourceId} | Reason: {ErrorMessage}",
auditEvent.TenantId,

View File

@@ -0,0 +1,148 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using ColaFlow.Modules.Mcp.Application.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ColaFlow.Modules.Mcp.Infrastructure.Authentication;
/// <summary>
/// Authentication handler for MCP API Key authentication.
/// This handler validates API keys in the Authorization header
/// and creates claims for the authenticated user.
/// </summary>
public class McpApiKeyAuthenticationHandler : AuthenticationHandler<McpApiKeyAuthenticationOptions>
{
private readonly IMcpApiKeyService _apiKeyService;
public McpApiKeyAuthenticationHandler(
IOptionsMonitor<McpApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IMcpApiKeyService apiKeyService)
: base(options, logger, encoder)
{
_apiKeyService = apiKeyService;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Extract API Key from Authorization header
var apiKey = ExtractApiKey();
if (string.IsNullOrEmpty(apiKey))
{
return AuthenticateResult.Fail("Missing API Key. Please provide Authorization: Bearer <api_key> header.");
}
// 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 SDK request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
return AuthenticateResult.Fail(validationResult.ErrorMessage ?? "Invalid API Key");
}
// Create claims from validation result
// Note: Use "tenant_id" to match ITenantContext implementations that look for this claim name
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, validationResult.UserId.ToString()),
new("ApiKeyId", validationResult.ApiKeyId.ToString()),
new("tenant_id", validationResult.TenantId.ToString()),
new("UserId", validationResult.UserId.ToString()),
};
// Add permission claims
if (validationResult.Permissions != null)
{
claims.Add(new Claim("McpPermissions:Read", validationResult.Permissions.Read.ToString()));
claims.Add(new Claim("McpPermissions:Write", validationResult.Permissions.Write.ToString()));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
// Store validation result in HttpContext.Items for downstream use
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 SDK request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
validationResult.ApiKeyId, validationResult.TenantId, validationResult.UserId);
return AuthenticateResult.Success(ticket);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = 401;
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 = "Missing or invalid API Key. Please provide Authorization: Bearer <api_key> header." }
},
id = (object?)null
};
var json = System.Text.Json.JsonSerializer.Serialize(errorResponse, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
return Response.WriteAsync(json);
}
/// <summary>
/// Extract API Key from Authorization header
/// Supports: Authorization: Bearer <api_key>
/// </summary>
private string? ExtractApiKey()
{
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
{
return null;
}
var authHeaderValue = authHeader.ToString();
if (string.IsNullOrWhiteSpace(authHeaderValue))
{
return null;
}
// Support "Bearer <api_key>" 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();
}
}
/// <summary>
/// Options for MCP API Key authentication
/// </summary>
public class McpApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
/// <summary>
/// The authentication scheme name
/// </summary>
public const string DefaultScheme = "McpApiKey";
}

View File

@@ -9,20 +9,15 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.BackgroundServices;
/// Background service to periodically expire old PendingChanges
/// Runs every 5 minutes and marks expired changes
/// </summary>
public class PendingChangeExpirationBackgroundService : BackgroundService
public class PendingChangeExpirationBackgroundService(
IServiceProvider serviceProvider,
ILogger<PendingChangeExpirationBackgroundService> logger)
: BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<PendingChangeExpirationBackgroundService> _logger;
private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
private readonly ILogger<PendingChangeExpirationBackgroundService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly TimeSpan _interval = TimeSpan.FromMinutes(5);
public PendingChangeExpirationBackgroundService(
IServiceProvider serviceProvider,
ILogger<PendingChangeExpirationBackgroundService> logger)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("PendingChange Expiration Background Service started");

View File

@@ -18,6 +18,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.4.0-preview.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
<PrivateAssets>all</PrivateAssets>

View File

@@ -3,11 +3,13 @@ using ColaFlow.Modules.Mcp.Application.Resources;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Contracts.Resources;
using ColaFlow.Modules.Mcp.Domain.Repositories;
using ColaFlow.Modules.Mcp.Infrastructure.Authentication;
using ColaFlow.Modules.Mcp.Infrastructure.BackgroundServices;
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
using ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
using ColaFlow.Modules.Mcp.Infrastructure.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -76,9 +78,25 @@ public static class McpServiceExtensions
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
// Note: MediatR handlers for MCP are registered in ModuleExtensions.cs AddProjectManagementModule()
// They are included via RegisterServicesFromAssembly for the MCP Application assembly
return services;
}
/// <summary>
/// Adds MCP API Key authentication scheme to the authentication builder.
/// This enables the /mcp-sdk endpoint to use API Key authentication.
/// </summary>
public static AuthenticationBuilder AddMcpApiKeyAuthentication(
this AuthenticationBuilder builder,
Action<McpApiKeyAuthenticationOptions>? configureOptions = null)
{
return builder.AddScheme<McpApiKeyAuthenticationOptions, McpApiKeyAuthenticationHandler>(
McpApiKeyAuthenticationOptions.DefaultScheme,
configureOptions ?? (_ => { }));
}
/// <summary>
/// Adds MCP middleware to the application pipeline
/// IMPORTANT: Middleware order matters - must be in this sequence:

View File

@@ -9,14 +9,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Hubs;
/// Supports notifying AI agents and users about PendingChange status updates
/// </summary>
[Authorize]
public class McpNotificationHub : Hub
public class McpNotificationHub(ILogger<McpNotificationHub> logger) : Hub
{
private readonly ILogger<McpNotificationHub> _logger;
public McpNotificationHub(ILogger<McpNotificationHub> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly ILogger<McpNotificationHub> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public override async Task OnConnectedAsync()
{

View File

@@ -9,25 +9,16 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// Middleware for authenticating MCP requests using API Keys
/// Only applies to /mcp endpoints
/// </summary>
public class McpApiKeyAuthenticationMiddleware
public class McpApiKeyAuthenticationMiddleware(
RequestDelegate next,
ILogger<McpApiKeyAuthenticationMiddleware> logger)
{
private readonly RequestDelegate _next;
private readonly ILogger<McpApiKeyAuthenticationMiddleware> _logger;
public McpApiKeyAuthenticationMiddleware(
RequestDelegate next,
ILogger<McpApiKeyAuthenticationMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IMcpApiKeyService apiKeyService)
{
// Only apply to /mcp endpoints
if (!context.Request.Path.StartsWithSegments("/mcp"))
// Only apply to /mcp-sdk endpoint (not /api/mcp/* which uses JWT auth)
if (!context.Request.Path.StartsWithSegments("/mcp-sdk"))
{
await _next(context);
await next(context);
return;
}
@@ -35,7 +26,7 @@ public class McpApiKeyAuthenticationMiddleware
var apiKey = ExtractApiKey(context.Request.Headers);
if (string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning("MCP request rejected - Missing API Key");
logger.LogWarning("MCP request rejected - Missing API Key");
await WriteUnauthorizedResponse(context, "Missing API Key. Please provide Authorization: Bearer <api_key> header.");
return;
}
@@ -47,7 +38,7 @@ public class McpApiKeyAuthenticationMiddleware
var validationResult = await apiKeyService.ValidateAsync(apiKey, ipAddress, context.RequestAborted);
if (!validationResult.IsValid)
{
_logger.LogWarning("MCP request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
logger.LogWarning("MCP request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
await WriteUnauthorizedResponse(context, validationResult.ErrorMessage ?? "Invalid API Key");
return;
}
@@ -59,10 +50,10 @@ public class McpApiKeyAuthenticationMiddleware
context.Items["McpUserId"] = validationResult.UserId;
context.Items["McpPermissions"] = validationResult.Permissions;
_logger.LogDebug("MCP request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
logger.LogDebug("MCP request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
validationResult.ApiKeyId, validationResult.TenantId, validationResult.UserId);
await _next(context);
await next(context);
}
/// <summary>

View File

@@ -10,16 +10,10 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// - Response headers (for client-side tracking)
/// - Serilog LogContext (for structured logging)
/// </summary>
public class McpCorrelationIdMiddleware
public class McpCorrelationIdMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeaderName = "X-Correlation-Id";
public McpCorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
// Try to get correlation ID from request header, otherwise generate new one
@@ -41,7 +35,7 @@ public class McpCorrelationIdMiddleware
// Add to Serilog LogContext so it appears in all log entries for this request
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
await next(context);
}
}

View File

@@ -10,24 +10,22 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// Global exception handler middleware for MCP requests
/// Catches all unhandled exceptions and converts them to JSON-RPC error responses
/// </summary>
public class McpExceptionHandlerMiddleware
public class McpExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<McpExceptionHandlerMiddleware> logger)
{
private readonly RequestDelegate _next;
private readonly ILogger<McpExceptionHandlerMiddleware> _logger;
public McpExceptionHandlerMiddleware(
RequestDelegate next,
ILogger<McpExceptionHandlerMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Only handle exceptions for MCP endpoints
if (!context.Request.Path.StartsWithSegments("/mcp-sdk"))
{
await next(context);
return;
}
try
{
await _next(context);
await next(context);
}
catch (McpException mcpEx)
{
@@ -49,7 +47,7 @@ public class McpExceptionHandlerMiddleware
var apiKeyId = context.Items["ApiKeyId"]?.ToString();
// Log the error with structured data
_logger.LogError(mcpEx,
logger.LogError(mcpEx,
"MCP Error: {ErrorCode} - {Message} | CorrelationId: {CorrelationId} | TenantId: {TenantId} | ApiKeyId: {ApiKeyId}",
mcpEx.ErrorCode, mcpEx.Message, correlationId, tenantId, apiKeyId);
@@ -84,7 +82,7 @@ public class McpExceptionHandlerMiddleware
var apiKeyId = context.Items["ApiKeyId"]?.ToString();
// Log the full exception with stack trace
_logger.LogError(ex,
logger.LogError(ex,
"Unexpected error in MCP Server | CorrelationId: {CorrelationId} | TenantId: {TenantId} | ApiKeyId: {ApiKeyId}",
correlationId, tenantId, apiKeyId);

View File

@@ -10,30 +10,21 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// Middleware that logs all MCP requests and responses
/// Includes performance timing and sensitive data sanitization
/// </summary>
public class McpLoggingMiddleware
public class McpLoggingMiddleware(
RequestDelegate next,
ILogger<McpLoggingMiddleware> logger)
{
private readonly RequestDelegate _next;
private readonly ILogger<McpLoggingMiddleware> _logger;
// Patterns for sanitizing sensitive data
private static readonly Regex ApiKeyHashPattern = new(@"""keyHash"":\s*""[^""]+""", RegexOptions.Compiled);
private static readonly Regex ApiKeyPattern = new(@"""apiKey"":\s*""[^""]+""", RegexOptions.Compiled);
private static readonly Regex PasswordPattern = new(@"""password"":\s*""[^""]+""", RegexOptions.Compiled);
public McpLoggingMiddleware(
RequestDelegate next,
ILogger<McpLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Only log MCP requests (POST to /mcp endpoint)
if (!IsMcpRequest(context))
{
await _next(context);
await next(context);
return;
}
@@ -54,7 +45,7 @@ public class McpLoggingMiddleware
try
{
// Execute the rest of the pipeline
await _next(context);
await next(context);
stopwatch.Stop();
@@ -73,11 +64,12 @@ public class McpLoggingMiddleware
/// <summary>
/// Checks if this is an MCP request
/// Only applies to /mcp-sdk endpoint (not /api/mcp/* which uses JWT auth)
/// </summary>
private static bool IsMcpRequest(HttpContext context)
{
return context.Request.Method == "POST"
&& context.Request.Path.StartsWithSegments("/mcp");
&& context.Request.Path.StartsWithSegments("/mcp-sdk");
}
/// <summary>
@@ -96,7 +88,7 @@ public class McpLoggingMiddleware
// Sanitize sensitive data before logging
var sanitizedBody = SanitizeSensitiveData(bodyText);
_logger.LogDebug(
logger.LogDebug(
"MCP Request | Method: {Method} | Path: {Path} | CorrelationId: {CorrelationId} | " +
"TenantId: {TenantId} | ApiKeyId: {ApiKeyId} | UserId: {UserId}\nBody: {Body}",
context.Request.Method,
@@ -123,7 +115,7 @@ public class McpLoggingMiddleware
var statusCode = context.Response.StatusCode;
var logLevel = statusCode >= 400 ? LogLevel.Error : LogLevel.Debug;
_logger.Log(logLevel,
logger.Log(logLevel,
"MCP Response | StatusCode: {StatusCode} | CorrelationId: {CorrelationId} | " +
"Duration: {Duration}ms\nBody: {Body}",
statusCode,
@@ -134,7 +126,7 @@ public class McpLoggingMiddleware
// Also log performance metrics
if (elapsedMs > 1000) // Log slow requests (> 1 second)
{
_logger.LogWarning(
logger.LogWarning(
"Slow MCP Request | CorrelationId: {CorrelationId} | Duration: {Duration}ms",
correlationId,
elapsedMs);

View File

@@ -9,27 +9,18 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Middleware;
/// <summary>
/// Middleware for handling MCP JSON-RPC 2.0 requests
/// </summary>
public class McpMiddleware
public class McpMiddleware(RequestDelegate next, ILogger<McpMiddleware> logger)
{
private readonly RequestDelegate _next;
private readonly ILogger<McpMiddleware> _logger;
public McpMiddleware(RequestDelegate next, ILogger<McpMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, IMcpProtocolHandler protocolHandler)
{
// Only handle POST requests to /mcp endpoint
if (context.Request.Method != "POST" || !context.Request.Path.StartsWithSegments("/mcp"))
// Only handle POST requests to /mcp-sdk endpoint (not /api/mcp/* which uses JWT auth)
if (context.Request.Method != "POST" || !context.Request.Path.StartsWithSegments("/mcp-sdk"))
{
await _next(context);
await next(context);
return;
}
_logger.LogDebug("MCP request received from {RemoteIp}", context.Connection.RemoteIpAddress);
logger.LogDebug("MCP request received from {RemoteIp}", context.Connection.RemoteIpAddress);
JsonRpcResponse? response = null;
JsonRpcRequest? request = null;
@@ -40,7 +31,7 @@ public class McpMiddleware
using var reader = new StreamReader(context.Request.Body);
var requestBody = await reader.ReadToEndAsync();
_logger.LogTrace("MCP request body: {RequestBody}", requestBody);
logger.LogTrace("MCP request body: {RequestBody}", requestBody);
// Parse JSON-RPC request
try
@@ -53,7 +44,7 @@ public class McpMiddleware
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse JSON-RPC request");
logger.LogWarning(ex, "Failed to parse JSON-RPC request");
response = JsonRpcResponse.ParseError(ex.Message);
}
@@ -75,7 +66,7 @@ public class McpMiddleware
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
_logger.LogTrace("MCP response: {ResponseJson}", responseJson);
logger.LogTrace("MCP response: {ResponseJson}", responseJson);
await context.Response.WriteAsync(responseJson);
}
@@ -83,12 +74,12 @@ public class McpMiddleware
{
// For notifications, return 204 No Content
context.Response.StatusCode = 204;
_logger.LogDebug("Notification processed, no response sent");
logger.LogDebug("Notification processed, no response sent");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception in MCP middleware");
logger.LogError(ex, "Unhandled exception in MCP middleware");
// Send internal error response (id is null because we don't know the request id)
response = JsonRpcResponse.InternalError("Unhandled server error", null);

View File

@@ -7,12 +7,8 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence;
/// <summary>
/// DbContext for MCP module
/// </summary>
public class McpDbContext : DbContext
public class McpDbContext(DbContextOptions<McpDbContext> options) : DbContext(options)
{
public McpDbContext(DbContextOptions<McpDbContext> options) : base(options)
{
}
public DbSet<McpApiKey> ApiKeys => Set<McpApiKey>();
public DbSet<PendingChange> PendingChanges => Set<PendingChange>();
public DbSet<TaskLock> TaskLocks => Set<TaskLock>();

View File

@@ -7,14 +7,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
/// <summary>
/// Repository implementation for MCP API Keys
/// </summary>
public class McpApiKeyRepository : IMcpApiKeyRepository
public class McpApiKeyRepository(McpDbContext context) : IMcpApiKeyRepository
{
private readonly McpDbContext _context;
public McpApiKeyRepository(McpDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
private readonly McpDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
public async Task<McpApiKey?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{

View File

@@ -8,14 +8,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
/// <summary>
/// Repository implementation for PendingChange aggregate
/// </summary>
public sealed class PendingChangeRepository : IPendingChangeRepository
public sealed class PendingChangeRepository(McpDbContext context) : IPendingChangeRepository
{
private readonly McpDbContext _context;
public PendingChangeRepository(McpDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
private readonly McpDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
public async Task<PendingChange?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{

View File

@@ -8,14 +8,9 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
/// <summary>
/// Repository implementation for TaskLock aggregate
/// </summary>
public sealed class TaskLockRepository : ITaskLockRepository
public sealed class TaskLockRepository(McpDbContext context) : ITaskLockRepository
{
private readonly McpDbContext _context;
public TaskLockRepository(McpDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
private readonly McpDbContext _context = context ?? throw new ArgumentNullException(nameof(context));
public async Task<TaskLock?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{

View File

@@ -79,19 +79,11 @@ public class SecurityScore
/// <summary>
/// Implementation of multi-tenant security report generator
/// </summary>
public class MultiTenantSecurityReportGenerator : IMultiTenantSecurityReportGenerator
public class MultiTenantSecurityReportGenerator(
IMcpSecurityAuditLogger? auditLogger = null,
ITenantContextValidator? tenantValidator = null)
: IMultiTenantSecurityReportGenerator
{
private readonly IMcpSecurityAuditLogger? _auditLogger;
private readonly ITenantContextValidator? _tenantValidator;
public MultiTenantSecurityReportGenerator(
IMcpSecurityAuditLogger? auditLogger = null,
ITenantContextValidator? tenantValidator = null)
{
_auditLogger = auditLogger;
_tenantValidator = tenantValidator;
}
/// <summary>
/// Generate comprehensive security report
/// </summary>
@@ -100,15 +92,15 @@ public class MultiTenantSecurityReportGenerator : IMultiTenantSecurityReportGene
var report = new MultiTenantSecurityReport();
// Gather audit statistics
if (_auditLogger != null)
if (auditLogger != null)
{
report.AuditStatistics = _auditLogger.GetAuditStatistics();
report.AuditStatistics = auditLogger.GetAuditStatistics();
}
// Gather validation statistics
if (_tenantValidator != null)
if (tenantValidator != null)
{
report.ValidationStatistics = _tenantValidator.GetValidationStats();
report.ValidationStatistics = tenantValidator.GetValidationStats();
}
// Perform security checks
@@ -276,7 +268,7 @@ public class MultiTenantSecurityReportGenerator : IMultiTenantSecurityReportGene
GlobalQueryFiltersEnabled = true, // Assumed (would need EF Core inspection)
ApiKeyTenantBindingEnabled = true, // Verified by API Key entity
CrossTenantAccessBlocked = true, // Verified by tests
AuditLoggingEnabled = _auditLogger != null
AuditLoggingEnabled = auditLogger != null
};
results.TotalChecks = 5;

View File

@@ -9,18 +9,13 @@ namespace ColaFlow.Modules.Mcp.Infrastructure.Services;
/// <summary>
/// Implementation of IMcpNotificationService using SignalR
/// </summary>
public class McpNotificationService : IMcpNotificationService
public class McpNotificationService(
IHubContext<McpNotificationHub> hubContext,
ILogger<McpNotificationService> logger)
: IMcpNotificationService
{
private readonly IHubContext<McpNotificationHub> _hubContext;
private readonly ILogger<McpNotificationService> _logger;
public McpNotificationService(
IHubContext<McpNotificationHub> hubContext,
ILogger<McpNotificationService> logger)
{
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private readonly IHubContext<McpNotificationHub> _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
private readonly ILogger<McpNotificationService> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public async Task NotifyPendingChangeCreatedAsync(
PendingChangeCreatedNotification notification,

View File

@@ -42,16 +42,9 @@ public class TenantValidationStats
/// Implementation of tenant context validator
/// Uses EF Core Query Tags and SQL inspection to verify tenant filtering
/// </summary>
public class TenantContextValidator : ITenantContextValidator
public class TenantContextValidator(ILogger<TenantContextValidator> logger) : ITenantContextValidator
{
private readonly ILogger<TenantContextValidator> _logger;
private readonly TenantValidationStats _stats;
public TenantContextValidator(ILogger<TenantContextValidator> logger)
{
_logger = logger;
_stats = new TenantValidationStats();
}
private readonly TenantValidationStats _stats = new();
/// <summary>
/// Validate that a query includes TenantId filter
@@ -61,7 +54,7 @@ public class TenantContextValidator : ITenantContextValidator
{
if (string.IsNullOrWhiteSpace(queryString))
{
_logger.LogWarning("Empty query string provided for validation");
logger.LogWarning("Empty query string provided for validation");
return false;
}
@@ -75,14 +68,14 @@ public class TenantContextValidator : ITenantContextValidator
if (hasTenantFilter)
{
_stats.QueriesWithTenantFilter++;
_logger.LogDebug("Query validation PASSED - TenantId filter present");
logger.LogDebug("Query validation PASSED - TenantId filter present");
return true;
}
else
{
_stats.QueriesWithoutTenantFilter++;
_stats.ViolatingQueries.Add(queryString);
_logger.LogWarning("SECURITY WARNING: Query validation FAILED - No TenantId filter detected: {Query}",
logger.LogWarning("SECURITY WARNING: Query validation FAILED - No TenantId filter detected: {Query}",
TruncateQuery(queryString));
return false;
}
@@ -96,7 +89,7 @@ public class TenantContextValidator : ITenantContextValidator
{
// Note: This would typically check HttpContext.Items["McpTenantId"]
// For now, we'll log a placeholder
_logger.LogDebug("Tenant context validation requested");
logger.LogDebug("Tenant context validation requested");
return true; // Placeholder - implement with actual context check
}

View File

@@ -12,6 +12,12 @@ public interface ITenantContext
/// <exception cref="UnauthorizedAccessException">Thrown when tenant context is not available</exception>
Guid GetCurrentTenantId();
/// <summary>
/// Tries to get the current tenant ID without throwing exceptions
/// </summary>
/// <returns>The current tenant ID or null if not available</returns>
Guid? TryGetCurrentTenantId();
/// <summary>
/// Gets the current user ID from claims (optional - may be null for system operations)
/// </summary>

View File

@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for EpicCreatedEvent - sends SignalR notification
/// </summary>
public class EpicCreatedEventHandler : INotificationHandler<EpicCreatedEvent>
public class EpicCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicCreatedEventHandler> logger)
: INotificationHandler<EpicCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<EpicCreatedEventHandler> _logger;
public EpicCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(EpicCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling EpicCreatedEvent for epic {EpicId}", notification.EpicId);
logger.LogInformation("Handling EpicCreatedEvent for epic {EpicId}", notification.EpicId);
var epicData = new
{
@@ -33,12 +25,12 @@ public class EpicCreatedEventHandler : INotificationHandler<EpicCreatedEvent>
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyEpicCreated(
await notificationService.NotifyEpicCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
epicData);
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
}
}

View File

@@ -8,28 +8,20 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for EpicDeletedEvent - sends SignalR notification
/// </summary>
public class EpicDeletedEventHandler : INotificationHandler<EpicDeletedEvent>
public class EpicDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicDeletedEventHandler> logger)
: INotificationHandler<EpicDeletedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<EpicDeletedEventHandler> _logger;
public EpicDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicDeletedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(EpicDeletedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling EpicDeletedEvent for epic {EpicId}", notification.EpicId);
logger.LogInformation("Handling EpicDeletedEvent for epic {EpicId}", notification.EpicId);
await _notificationService.NotifyEpicDeleted(
await notificationService.NotifyEpicDeleted(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value);
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
}
}

View File

@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for EpicUpdatedEvent - sends SignalR notification
/// </summary>
public class EpicUpdatedEventHandler : INotificationHandler<EpicUpdatedEvent>
public class EpicUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicUpdatedEventHandler> logger)
: INotificationHandler<EpicUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<EpicUpdatedEventHandler> _logger;
public EpicUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<EpicUpdatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(EpicUpdatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling EpicUpdatedEvent for epic {EpicId}", notification.EpicId);
logger.LogInformation("Handling EpicUpdatedEvent for epic {EpicId}", notification.EpicId);
var epicData = new
{
@@ -33,12 +25,12 @@ public class EpicUpdatedEventHandler : INotificationHandler<EpicUpdatedEvent>
UpdatedAt = DateTime.UtcNow
};
await _notificationService.NotifyEpicUpdated(
await notificationService.NotifyEpicUpdated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
epicData);
_logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
logger.LogInformation("SignalR notification sent for epic {EpicId}", notification.EpicId);
}
}

View File

@@ -9,38 +9,28 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for ProjectArchivedEvent - sends SignalR notification
/// </summary>
public class ProjectArchivedEventHandler : INotificationHandler<ProjectArchivedEvent>
public class ProjectArchivedEventHandler(
IProjectNotificationService notificationService,
IProjectRepository projectRepository,
ILogger<ProjectArchivedEventHandler> logger)
: INotificationHandler<ProjectArchivedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly IProjectRepository _projectRepository;
private readonly ILogger<ProjectArchivedEventHandler> _logger;
public ProjectArchivedEventHandler(
IProjectNotificationService notificationService,
IProjectRepository projectRepository,
ILogger<ProjectArchivedEventHandler> logger)
{
_notificationService = notificationService;
_projectRepository = projectRepository;
_logger = logger;
}
public async Task Handle(ProjectArchivedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling ProjectArchivedEvent for project {ProjectId}", notification.ProjectId);
logger.LogInformation("Handling ProjectArchivedEvent for project {ProjectId}", notification.ProjectId);
// Get full project to obtain TenantId
var project = await _projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
var project = await projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
if (project == null)
{
_logger.LogWarning("Project {ProjectId} not found for archive notification", notification.ProjectId);
logger.LogWarning("Project {ProjectId} not found for archive notification", notification.ProjectId);
return;
}
await _notificationService.NotifyProjectArchived(
await notificationService.NotifyProjectArchived(
project.TenantId.Value,
notification.ProjectId.Value);
_logger.LogInformation("SignalR notification sent for archived project {ProjectId}", notification.ProjectId);
logger.LogInformation("SignalR notification sent for archived project {ProjectId}", notification.ProjectId);
}
}

View File

@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for ProjectCreatedEvent - sends SignalR notification
/// </summary>
public class ProjectCreatedEventHandler : INotificationHandler<ProjectCreatedEvent>
public class ProjectCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<ProjectCreatedEventHandler> logger)
: INotificationHandler<ProjectCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<ProjectCreatedEventHandler> _logger;
public ProjectCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<ProjectCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(ProjectCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling ProjectCreatedEvent for project {ProjectId}", notification.ProjectId);
logger.LogInformation("Handling ProjectCreatedEvent for project {ProjectId}", notification.ProjectId);
var projectData = new
{
@@ -33,11 +25,11 @@ public class ProjectCreatedEventHandler : INotificationHandler<ProjectCreatedEve
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyProjectCreated(
await notificationService.NotifyProjectCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
projectData);
_logger.LogInformation("SignalR notification sent for project {ProjectId}", notification.ProjectId);
logger.LogInformation("SignalR notification sent for project {ProjectId}", notification.ProjectId);
}
}

View File

@@ -9,31 +9,21 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for ProjectUpdatedEvent - sends SignalR notification
/// </summary>
public class ProjectUpdatedEventHandler : INotificationHandler<ProjectUpdatedEvent>
public class ProjectUpdatedEventHandler(
IProjectNotificationService notificationService,
IProjectRepository projectRepository,
ILogger<ProjectUpdatedEventHandler> logger)
: INotificationHandler<ProjectUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly IProjectRepository _projectRepository;
private readonly ILogger<ProjectUpdatedEventHandler> _logger;
public ProjectUpdatedEventHandler(
IProjectNotificationService notificationService,
IProjectRepository projectRepository,
ILogger<ProjectUpdatedEventHandler> logger)
{
_notificationService = notificationService;
_projectRepository = projectRepository;
_logger = logger;
}
public async Task Handle(ProjectUpdatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling ProjectUpdatedEvent for project {ProjectId}", notification.ProjectId);
logger.LogInformation("Handling ProjectUpdatedEvent for project {ProjectId}", notification.ProjectId);
// Get full project to obtain TenantId
var project = await _projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
var project = await projectRepository.GetByIdAsync(notification.ProjectId, cancellationToken);
if (project == null)
{
_logger.LogWarning("Project {ProjectId} not found for update notification", notification.ProjectId);
logger.LogWarning("Project {ProjectId} not found for update notification", notification.ProjectId);
return;
}
@@ -45,11 +35,11 @@ public class ProjectUpdatedEventHandler : INotificationHandler<ProjectUpdatedEve
UpdatedAt = DateTime.UtcNow
};
await _notificationService.NotifyProjectUpdated(
await notificationService.NotifyProjectUpdated(
project.TenantId.Value,
notification.ProjectId.Value,
projectData);
_logger.LogInformation("SignalR notification sent for updated project {ProjectId}", notification.ProjectId);
logger.LogInformation("SignalR notification sent for updated project {ProjectId}", notification.ProjectId);
}
}

View File

@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for StoryCreatedEvent - sends SignalR notification
/// </summary>
public class StoryCreatedEventHandler : INotificationHandler<StoryCreatedEvent>
public class StoryCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryCreatedEventHandler> logger)
: INotificationHandler<StoryCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<StoryCreatedEventHandler> _logger;
public StoryCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(StoryCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling StoryCreatedEvent for story {StoryId}", notification.StoryId);
logger.LogInformation("Handling StoryCreatedEvent for story {StoryId}", notification.StoryId);
var storyData = new
{
@@ -34,13 +26,13 @@ public class StoryCreatedEventHandler : INotificationHandler<StoryCreatedEvent>
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyStoryCreated(
await notificationService.NotifyStoryCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
notification.StoryId.Value,
storyData);
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
}
}

View File

@@ -8,29 +8,21 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for StoryDeletedEvent - sends SignalR notification
/// </summary>
public class StoryDeletedEventHandler : INotificationHandler<StoryDeletedEvent>
public class StoryDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryDeletedEventHandler> logger)
: INotificationHandler<StoryDeletedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<StoryDeletedEventHandler> _logger;
public StoryDeletedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryDeletedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(StoryDeletedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling StoryDeletedEvent for story {StoryId}", notification.StoryId);
logger.LogInformation("Handling StoryDeletedEvent for story {StoryId}", notification.StoryId);
await _notificationService.NotifyStoryDeleted(
await notificationService.NotifyStoryDeleted(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
notification.StoryId.Value);
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
}
}

View File

@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for StoryUpdatedEvent - sends SignalR notification
/// </summary>
public class StoryUpdatedEventHandler : INotificationHandler<StoryUpdatedEvent>
public class StoryUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryUpdatedEventHandler> logger)
: INotificationHandler<StoryUpdatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<StoryUpdatedEventHandler> _logger;
public StoryUpdatedEventHandler(
IProjectNotificationService notificationService,
ILogger<StoryUpdatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(StoryUpdatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling StoryUpdatedEvent for story {StoryId}", notification.StoryId);
logger.LogInformation("Handling StoryUpdatedEvent for story {StoryId}", notification.StoryId);
var storyData = new
{
@@ -34,13 +26,13 @@ public class StoryUpdatedEventHandler : INotificationHandler<StoryUpdatedEvent>
UpdatedAt = DateTime.UtcNow
};
await _notificationService.NotifyStoryUpdated(
await notificationService.NotifyStoryUpdated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.EpicId.Value,
notification.StoryId.Value,
storyData);
_logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
logger.LogInformation("SignalR notification sent for story {StoryId}", notification.StoryId);
}
}

View File

@@ -8,30 +8,22 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for TaskAssignedEvent - sends SignalR notification
/// </summary>
public class TaskAssignedEventHandler : INotificationHandler<TaskAssignedEvent>
public class TaskAssignedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskAssignedEventHandler> logger)
: INotificationHandler<TaskAssignedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<TaskAssignedEventHandler> _logger;
public TaskAssignedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskAssignedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(TaskAssignedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling TaskAssignedEvent for task {TaskId}", notification.TaskId);
logger.LogInformation("Handling TaskAssignedEvent for task {TaskId}", notification.TaskId);
await _notificationService.NotifyTaskAssigned(
await notificationService.NotifyTaskAssigned(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.TaskId.Value,
notification.AssigneeId.Value);
_logger.LogInformation("SignalR notification sent for task {TaskId} assigned to {AssigneeId}",
logger.LogInformation("SignalR notification sent for task {TaskId} assigned to {AssigneeId}",
notification.TaskId, notification.AssigneeId);
}
}

View File

@@ -8,22 +8,14 @@ namespace ColaFlow.Modules.ProjectManagement.Application.EventHandlers;
/// <summary>
/// Handler for TaskCreatedEvent - sends SignalR notification
/// </summary>
public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
public class TaskCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskCreatedEventHandler> logger)
: INotificationHandler<TaskCreatedEvent>
{
private readonly IProjectNotificationService _notificationService;
private readonly ILogger<TaskCreatedEventHandler> _logger;
public TaskCreatedEventHandler(
IProjectNotificationService notificationService,
ILogger<TaskCreatedEventHandler> logger)
{
_notificationService = notificationService;
_logger = logger;
}
public async Task Handle(TaskCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation("Handling TaskCreatedEvent for task {TaskId}", notification.TaskId);
logger.LogInformation("Handling TaskCreatedEvent for task {TaskId}", notification.TaskId);
var taskData = new
{
@@ -34,13 +26,13 @@ public class TaskCreatedEventHandler : INotificationHandler<TaskCreatedEvent>
CreatedAt = DateTime.UtcNow
};
await _notificationService.NotifyTaskCreated(
await notificationService.NotifyTaskCreated(
notification.TenantId.Value,
notification.ProjectId.Value,
notification.StoryId.Value,
notification.TaskId.Value,
taskData);
_logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
logger.LogInformation("SignalR notification sent for task {TaskId}", notification.TaskId);
}
}

Some files were not shown because too many files have changed in this diff Show More