Files
ColaFlow/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Infrastructure/Services/McpNotificationService.cs
Yaojia Wang 9ccd3284fb feat(backend): Implement SignalR Real-Time Notifications for MCP - Story 5.12
Implemented comprehensive real-time notification system using SignalR to notify
AI agents and users about PendingChange status updates.

Key Features Implemented:
- McpNotificationHub with Subscribe/Unsubscribe methods
- Real-time notifications for all PendingChange lifecycle events
- Tenant-based isolation for multi-tenancy security
- Notification DTOs for structured message formats
- Domain event handlers for automatic notification sending
- Comprehensive unit tests for notification service and handlers
- Client integration guide with examples for TypeScript, React, and Python

Components Created:
1. SignalR Hub:
   - McpNotificationHub.cs - Central hub for MCP notifications

2. Notification DTOs:
   - PendingChangeNotification.cs (base class)
   - PendingChangeCreatedNotification.cs
   - PendingChangeApprovedNotification.cs
   - PendingChangeRejectedNotification.cs
   - PendingChangeAppliedNotification.cs
   - PendingChangeExpiredNotification.cs

3. Notification Service:
   - IMcpNotificationService.cs (interface)
   - McpNotificationService.cs (implementation using SignalR)

4. Event Handlers (send notifications):
   - PendingChangeCreatedNotificationHandler.cs
   - PendingChangeApprovedNotificationHandler.cs
   - PendingChangeRejectedNotificationHandler.cs
   - PendingChangeAppliedNotificationHandler.cs
   - PendingChangeExpiredNotificationHandler.cs

5. Tests:
   - McpNotificationServiceTests.cs - Unit tests for notification service
   - PendingChangeCreatedNotificationHandlerTests.cs
   - PendingChangeApprovedNotificationHandlerTests.cs

6. Documentation:
   - signalr-mcp-client-guide.md - Comprehensive client integration guide

Technical Details:
- Hub endpoint: /hubs/mcp-notifications
- Authentication: JWT token via query string (?access_token=xxx)
- Tenant isolation: Automatic group joining based on tenant ID
- Group subscriptions: Per-pending-change and per-tenant groups
- Notification delivery: < 1 second (real-time)
- Fallback strategy: Polling if WebSocket unavailable

Architecture Benefits:
- Decoupled design using domain events
- Notification failures don't break main flow
- Scalable (supports Redis backplane for multi-instance)
- Type-safe notification payloads
- Tenant isolation built-in

Story: Phase 3 - Tools & Diff Preview
Priority: P0 CRITICAL
Story Points: 3
Completion: 100%

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:21:08 +01:00

136 lines
5.6 KiB
C#

using ColaFlow.API.Hubs;
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Infrastructure.Services;
/// <summary>
/// Implementation of IMcpNotificationService using SignalR
/// </summary>
public class McpNotificationService : 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));
}
public async Task NotifyPendingChangeCreatedAsync(
PendingChangeCreatedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeCreated notification - PendingChangeId={PendingChangeId}, EntityType={EntityType}, Operation={Operation}",
notification.PendingChangeId, notification.EntityType, notification.Operation);
// Send to both: specific pending change subscribers AND all tenant members
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeCreated", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeCreated notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeApprovedAsync(
PendingChangeApprovedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeApproved notification - PendingChangeId={PendingChangeId}, EntityType={EntityType}, ApprovedBy={ApprovedBy}",
notification.PendingChangeId, notification.EntityType, notification.ApprovedBy);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeApproved", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeApproved notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeRejectedAsync(
PendingChangeRejectedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeRejected notification - PendingChangeId={PendingChangeId}, Reason={Reason}, RejectedBy={RejectedBy}",
notification.PendingChangeId, notification.Reason, notification.RejectedBy);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeRejected", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeRejected notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeAppliedAsync(
PendingChangeAppliedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeApplied notification - PendingChangeId={PendingChangeId}, Result={Result}",
notification.PendingChangeId, notification.Result);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeApplied", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeApplied notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeExpiredAsync(
PendingChangeExpiredNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeExpired notification - PendingChangeId={PendingChangeId}, ExpiredAt={ExpiredAt}",
notification.PendingChangeId, notification.ExpiredAt);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeExpired", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeExpired notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
private static string GetPendingChangeGroupName(Guid pendingChangeId)
{
return $"pending-change-{pendingChangeId}";
}
private static string GetTenantGroupName(Guid tenantId)
{
return $"tenant-{tenantId}";
}
}