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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
|
||||
using ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
using ColaFlow.Modules.Mcp.Application.Services;
|
||||
using ColaFlow.Modules.Mcp.Domain.Events;
|
||||
using ColaFlow.Modules.Mcp.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Tests.EventHandlers;
|
||||
|
||||
public class PendingChangeApprovedNotificationHandlerTests
|
||||
{
|
||||
private readonly Mock<IMcpNotificationService> _mockNotificationService;
|
||||
private readonly Mock<ILogger<PendingChangeApprovedNotificationHandler>> _mockLogger;
|
||||
private readonly PendingChangeApprovedNotificationHandler _handler;
|
||||
|
||||
public PendingChangeApprovedNotificationHandlerTests()
|
||||
{
|
||||
_mockNotificationService = new Mock<IMcpNotificationService>();
|
||||
_mockLogger = new Mock<ILogger<PendingChangeApprovedNotificationHandler>>();
|
||||
|
||||
_handler = new PendingChangeApprovedNotificationHandler(
|
||||
_mockNotificationService.Object,
|
||||
_mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SendsNotification_WithCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var approvedBy = Guid.NewGuid();
|
||||
var entityId = Guid.NewGuid();
|
||||
|
||||
var diff = new DiffPreview(
|
||||
"CREATE",
|
||||
"Epic",
|
||||
entityId,
|
||||
null,
|
||||
null,
|
||||
"{\"name\":\"Test Epic\"}",
|
||||
new List<DiffField>());
|
||||
|
||||
var domainEvent = new PendingChangeApprovedEvent(
|
||||
pendingChangeId,
|
||||
"create_epic",
|
||||
diff,
|
||||
approvedBy,
|
||||
tenantId);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(domainEvent, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockNotificationService.Verify(
|
||||
s => s.NotifyPendingChangeApprovedAsync(
|
||||
It.Is<PendingChangeApprovedNotification>(n =>
|
||||
n.PendingChangeId == pendingChangeId &&
|
||||
n.ToolName == "create_epic" &&
|
||||
n.EntityType == "Epic" &&
|
||||
n.Operation == "CREATE" &&
|
||||
n.EntityId == entityId &&
|
||||
n.ApprovedBy == approvedBy &&
|
||||
n.TenantId == tenantId),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DoesNotThrow_WhenNotificationServiceFails()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var approvedBy = Guid.NewGuid();
|
||||
|
||||
var diff = new DiffPreview(
|
||||
"CREATE",
|
||||
"Epic",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"{\"name\":\"Test Epic\"}",
|
||||
new List<DiffField>());
|
||||
|
||||
var domainEvent = new PendingChangeApprovedEvent(
|
||||
pendingChangeId,
|
||||
"create_epic",
|
||||
diff,
|
||||
approvedBy,
|
||||
tenantId);
|
||||
|
||||
_mockNotificationService.Setup(s => s.NotifyPendingChangeApprovedAsync(
|
||||
It.IsAny<PendingChangeApprovedNotification>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("SignalR connection failed"));
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await _handler.Handle(domainEvent, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
|
||||
using ColaFlow.Modules.Mcp.Application.EventHandlers;
|
||||
using ColaFlow.Modules.Mcp.Application.Services;
|
||||
using ColaFlow.Modules.Mcp.Domain.Entities;
|
||||
using ColaFlow.Modules.Mcp.Domain.Events;
|
||||
using ColaFlow.Modules.Mcp.Domain.Repositories;
|
||||
using ColaFlow.Modules.Mcp.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Tests.EventHandlers;
|
||||
|
||||
public class PendingChangeCreatedNotificationHandlerTests
|
||||
{
|
||||
private readonly Mock<IMcpNotificationService> _mockNotificationService;
|
||||
private readonly Mock<IPendingChangeRepository> _mockRepository;
|
||||
private readonly Mock<ILogger<PendingChangeCreatedNotificationHandler>> _mockLogger;
|
||||
private readonly PendingChangeCreatedNotificationHandler _handler;
|
||||
|
||||
public PendingChangeCreatedNotificationHandlerTests()
|
||||
{
|
||||
_mockNotificationService = new Mock<IMcpNotificationService>();
|
||||
_mockRepository = new Mock<IPendingChangeRepository>();
|
||||
_mockLogger = new Mock<ILogger<PendingChangeCreatedNotificationHandler>>();
|
||||
|
||||
_handler = new PendingChangeCreatedNotificationHandler(
|
||||
_mockNotificationService.Object,
|
||||
_mockRepository.Object,
|
||||
_mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SendsNotification_WhenPendingChangeExists()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var apiKeyId = Guid.NewGuid();
|
||||
|
||||
var domainEvent = new PendingChangeCreatedEvent(
|
||||
pendingChangeId,
|
||||
"create_epic",
|
||||
"Epic",
|
||||
"CREATE",
|
||||
tenantId);
|
||||
|
||||
var diff = new DiffPreview(
|
||||
"CREATE",
|
||||
"Epic",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"{\"name\":\"Test Epic\"}",
|
||||
new List<DiffField>());
|
||||
|
||||
var pendingChange = PendingChange.Create(
|
||||
"create_epic",
|
||||
diff,
|
||||
tenantId,
|
||||
apiKeyId,
|
||||
12);
|
||||
|
||||
_mockRepository.Setup(r => r.GetByIdAsync(pendingChangeId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pendingChange);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(domainEvent, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockNotificationService.Verify(
|
||||
s => s.NotifyPendingChangeCreatedAsync(
|
||||
It.Is<PendingChangeCreatedNotification>(n =>
|
||||
n.PendingChangeId == pendingChangeId &&
|
||||
n.ToolName == "create_epic" &&
|
||||
n.EntityType == "Epic" &&
|
||||
n.Operation == "CREATE" &&
|
||||
n.TenantId == tenantId),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DoesNotSendNotification_WhenPendingChangeNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
var domainEvent = new PendingChangeCreatedEvent(
|
||||
pendingChangeId,
|
||||
"create_epic",
|
||||
"Epic",
|
||||
"CREATE",
|
||||
tenantId);
|
||||
|
||||
_mockRepository.Setup(r => r.GetByIdAsync(pendingChangeId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((PendingChange?)null);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(domainEvent, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockNotificationService.Verify(
|
||||
s => s.NotifyPendingChangeCreatedAsync(
|
||||
It.IsAny<PendingChangeCreatedNotification>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DoesNotThrow_WhenNotificationServiceFails()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var apiKeyId = Guid.NewGuid();
|
||||
|
||||
var domainEvent = new PendingChangeCreatedEvent(
|
||||
pendingChangeId,
|
||||
"create_epic",
|
||||
"Epic",
|
||||
"CREATE",
|
||||
tenantId);
|
||||
|
||||
var diff = new DiffPreview(
|
||||
"CREATE",
|
||||
"Epic",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"{\"name\":\"Test Epic\"}",
|
||||
new List<DiffField>());
|
||||
|
||||
var pendingChange = PendingChange.Create(
|
||||
"create_epic",
|
||||
diff,
|
||||
tenantId,
|
||||
apiKeyId,
|
||||
12);
|
||||
|
||||
_mockRepository.Setup(r => r.GetByIdAsync(pendingChangeId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(pendingChange);
|
||||
|
||||
_mockNotificationService.Setup(s => s.NotifyPendingChangeCreatedAsync(
|
||||
It.IsAny<PendingChangeCreatedNotification>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new Exception("SignalR connection failed"));
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await _handler.Handle(domainEvent, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using ColaFlow.API.Hubs;
|
||||
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Tests.Services;
|
||||
|
||||
public class McpNotificationServiceTests
|
||||
{
|
||||
private readonly Mock<IHubContext<McpNotificationHub>> _mockHubContext;
|
||||
private readonly Mock<ILogger<McpNotificationService>> _mockLogger;
|
||||
private readonly Mock<IHubClients> _mockClients;
|
||||
private readonly Mock<IClientProxy> _mockClientProxy;
|
||||
private readonly McpNotificationService _service;
|
||||
|
||||
public McpNotificationServiceTests()
|
||||
{
|
||||
_mockHubContext = new Mock<IHubContext<McpNotificationHub>>();
|
||||
_mockLogger = new Mock<ILogger<McpNotificationService>>();
|
||||
_mockClients = new Mock<IHubClients>();
|
||||
_mockClientProxy = new Mock<IClientProxy>();
|
||||
|
||||
_mockHubContext.Setup(h => h.Clients).Returns(_mockClients.Object);
|
||||
|
||||
_service = new McpNotificationService(_mockHubContext.Object, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyPendingChangeCreatedAsync_SendsNotificationToCorrectGroups()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var notification = new PendingChangeCreatedNotification
|
||||
{
|
||||
NotificationType = "PendingChangeCreated",
|
||||
PendingChangeId = pendingChangeId,
|
||||
ToolName = "create_epic",
|
||||
EntityType = "Epic",
|
||||
Operation = "CREATE",
|
||||
Summary = "Create Epic: Test Epic",
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(_mockClientProxy.Object);
|
||||
|
||||
// Act
|
||||
await _service.NotifyPendingChangeCreatedAsync(notification);
|
||||
|
||||
// Assert
|
||||
_mockClients.Verify(
|
||||
c => c.Groups($"pending-change-{pendingChangeId}", $"tenant-{tenantId}"),
|
||||
Times.Once);
|
||||
|
||||
_mockClientProxy.Verify(
|
||||
p => p.SendCoreAsync(
|
||||
"PendingChangeCreated",
|
||||
It.Is<object[]>(args => args.Length == 1 && args[0] == notification),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyPendingChangeApprovedAsync_SendsNotificationWithCorrectData()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var approvedBy = Guid.NewGuid();
|
||||
var entityId = Guid.NewGuid();
|
||||
|
||||
var notification = new PendingChangeApprovedNotification
|
||||
{
|
||||
NotificationType = "PendingChangeApproved",
|
||||
PendingChangeId = pendingChangeId,
|
||||
ToolName = "create_epic",
|
||||
EntityType = "Epic",
|
||||
Operation = "CREATE",
|
||||
EntityId = entityId,
|
||||
ApprovedBy = approvedBy,
|
||||
ExecutionResult = "Epic created: Test Epic",
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(_mockClientProxy.Object);
|
||||
|
||||
// Act
|
||||
await _service.NotifyPendingChangeApprovedAsync(notification);
|
||||
|
||||
// Assert
|
||||
_mockClientProxy.Verify(
|
||||
p => p.SendCoreAsync(
|
||||
"PendingChangeApproved",
|
||||
It.Is<object[]>(args =>
|
||||
args.Length == 1 &&
|
||||
((PendingChangeApprovedNotification)args[0]).ApprovedBy == approvedBy &&
|
||||
((PendingChangeApprovedNotification)args[0]).EntityId == entityId),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyPendingChangeRejectedAsync_SendsNotificationWithReason()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var rejectedBy = Guid.NewGuid();
|
||||
var reason = "Epic name is too vague";
|
||||
|
||||
var notification = new PendingChangeRejectedNotification
|
||||
{
|
||||
NotificationType = "PendingChangeRejected",
|
||||
PendingChangeId = pendingChangeId,
|
||||
ToolName = "create_epic",
|
||||
Reason = reason,
|
||||
RejectedBy = rejectedBy,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(_mockClientProxy.Object);
|
||||
|
||||
// Act
|
||||
await _service.NotifyPendingChangeRejectedAsync(notification);
|
||||
|
||||
// Assert
|
||||
_mockClientProxy.Verify(
|
||||
p => p.SendCoreAsync(
|
||||
"PendingChangeRejected",
|
||||
It.Is<object[]>(args =>
|
||||
args.Length == 1 &&
|
||||
((PendingChangeRejectedNotification)args[0]).Reason == reason &&
|
||||
((PendingChangeRejectedNotification)args[0]).RejectedBy == rejectedBy),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyPendingChangeAppliedAsync_SendsNotificationWithResult()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var result = "Epic created: abc123 - Test Epic";
|
||||
|
||||
var notification = new PendingChangeAppliedNotification
|
||||
{
|
||||
NotificationType = "PendingChangeApplied",
|
||||
PendingChangeId = pendingChangeId,
|
||||
ToolName = "create_epic",
|
||||
Result = result,
|
||||
AppliedAt = DateTime.UtcNow,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(_mockClientProxy.Object);
|
||||
|
||||
// Act
|
||||
await _service.NotifyPendingChangeAppliedAsync(notification);
|
||||
|
||||
// Assert
|
||||
_mockClientProxy.Verify(
|
||||
p => p.SendCoreAsync(
|
||||
"PendingChangeApplied",
|
||||
It.Is<object[]>(args =>
|
||||
args.Length == 1 &&
|
||||
((PendingChangeAppliedNotification)args[0]).Result == result),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotifyPendingChangeExpiredAsync_SendsNotificationToCorrectGroups()
|
||||
{
|
||||
// Arrange
|
||||
var pendingChangeId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
|
||||
var notification = new PendingChangeExpiredNotification
|
||||
{
|
||||
NotificationType = "PendingChangeExpired",
|
||||
PendingChangeId = pendingChangeId,
|
||||
ToolName = "create_epic",
|
||||
ExpiredAt = DateTime.UtcNow,
|
||||
TenantId = tenantId
|
||||
};
|
||||
|
||||
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(_mockClientProxy.Object);
|
||||
|
||||
// Act
|
||||
await _service.NotifyPendingChangeExpiredAsync(notification);
|
||||
|
||||
// Assert
|
||||
_mockClients.Verify(
|
||||
c => c.Groups($"pending-change-{pendingChangeId}", $"tenant-{tenantId}"),
|
||||
Times.Once);
|
||||
|
||||
_mockClientProxy.Verify(
|
||||
p => p.SendCoreAsync(
|
||||
"PendingChangeExpired",
|
||||
It.Is<object[]>(args => args.Length == 1),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenHubContextIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
new McpNotificationService(null!, _mockLogger.Object));
|
||||
|
||||
Assert.Equal("hubContext", exception.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
new McpNotificationService(_mockHubContext.Object, null!));
|
||||
|
||||
Assert.Equal("logger", exception.ParamName);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user