--- story_id: story_5_12 sprint_id: sprint_5 phase: Phase 3 - Tools & Diff Preview status: not_started priority: P0 story_points: 3 assignee: backend estimated_days: 1 created_date: 2025-11-06 dependencies: [story_5_10] --- # Story 5.12: SignalR Real-Time Notifications **Phase**: Phase 3 - Tools & Diff Preview (Week 5-6) **Priority**: P0 CRITICAL **Estimated Effort**: 3 Story Points (1 day) ## User Story **As an** AI Agent **I want** to receive real-time notifications when my pending changes are approved or rejected **So that** I can continue my workflow without polling ## Business Value Real-time notifications complete the AI interaction loop: - **Faster Feedback**: AI knows approval result within 1 second - **Better UX**: No polling, immediate response - **Scalability**: WebSocket more efficient than polling - **Future-Proof**: Foundation for other real-time AI features ## Acceptance Criteria ### AC1: McpHub SignalR Hub - [ ] Create `McpHub` SignalR hub - [ ] Support `SubscribeToPendingChange(pendingChangeId)` method - [ ] Support `UnsubscribeFromPendingChange(pendingChangeId)` method - [ ] Authenticate connections via API Key ### AC2: Approval Notification - [ ] When PendingChange approved → push notification to AI - [ ] Notification includes: pendingChangeId, status, execution result - [ ] Delivered within 1 second of approval - [ ] Use SignalR groups for targeting ### AC3: Rejection Notification - [ ] When PendingChange rejected → push notification to AI - [ ] Notification includes: pendingChangeId, status, rejection reason - [ ] Delivered within 1 second of rejection ### AC4: Expiration Notification - [ ] When PendingChange expired → push notification to AI - [ ] Notification includes: pendingChangeId, status ### AC5: Connection Management - [ ] API Key authentication for SignalR connections - [ ] Handle disconnections gracefully - [ ] Reconnection support - [ ] Fallback to polling if WebSocket unavailable ### AC6: Testing - [ ] Unit tests for event handlers - [ ] Integration tests for SignalR notifications - [ ] Test connection authentication - [ ] Test group subscriptions ## Technical Design ### SignalR Hub ```csharp [Authorize] // Require JWT for REST API integration public class McpHub : Hub { private readonly ILogger _logger; public McpHub(ILogger logger) { _logger = logger; } public override async Task OnConnectedAsync() { var connectionId = Context.ConnectionId; var apiKeyId = Context.Items["ApiKeyId"]; var tenantId = Context.Items["TenantId"]; _logger.LogInformation( "MCP client connected: {ConnectionId} (ApiKeyId: {ApiKeyId}, TenantId: {TenantId})", connectionId, apiKeyId, tenantId); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { var connectionId = Context.ConnectionId; if (exception != null) { _logger.LogError(exception, "MCP client disconnected with error: {ConnectionId}", connectionId); } else { _logger.LogInformation( "MCP client disconnected: {ConnectionId}", connectionId); } await base.OnDisconnectedAsync(exception); } public async Task SubscribeToPendingChange(Guid pendingChangeId) { var groupName = $"pending-change-{pendingChangeId}"; await Groups.AddToGroupAsync(Context.ConnectionId, groupName); _logger.LogInformation( "Client {ConnectionId} subscribed to {GroupName}", Context.ConnectionId, groupName); } public async Task UnsubscribeFromPendingChange(Guid pendingChangeId) { var groupName = $"pending-change-{pendingChangeId}"; await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName); _logger.LogInformation( "Client {ConnectionId} unsubscribed from {GroupName}", Context.ConnectionId, groupName); } } ``` ### PendingChangeApproved Notification ```csharp public class PendingChangeApprovedEventHandler : INotificationHandler { private readonly IHubContext _hubContext; private readonly ILogger _logger; public async Task Handle(PendingChangeApprovedEvent e, CancellationToken ct) { var groupName = $"pending-change-{e.PendingChangeId}"; var notification = new { PendingChangeId = e.PendingChangeId, Status = "Approved", ToolName = e.ToolName, Operation = e.Diff.Operation, EntityType = e.Diff.EntityType, EntityId = e.Diff.EntityId, ApprovedBy = e.ApprovedBy, Timestamp = DateTime.UtcNow }; await _hubContext.Clients .Group(groupName) .SendAsync("PendingChangeApproved", notification, ct); _logger.LogInformation( "Sent PendingChangeApproved notification: {PendingChangeId}", e.PendingChangeId); } } ``` ### PendingChangeRejected Notification ```csharp public class PendingChangeRejectedEventHandler : INotificationHandler { private readonly IHubContext _hubContext; private readonly ILogger _logger; public async Task Handle(PendingChangeRejectedEvent e, CancellationToken ct) { var groupName = $"pending-change-{e.PendingChangeId}"; var notification = new { PendingChangeId = e.PendingChangeId, Status = "Rejected", ToolName = e.ToolName, Reason = e.Reason, RejectedBy = e.RejectedBy, Timestamp = DateTime.UtcNow }; await _hubContext.Clients .Group(groupName) .SendAsync("PendingChangeRejected", notification, ct); _logger.LogInformation( "Sent PendingChangeRejected notification: {PendingChangeId} - Reason: {Reason}", e.PendingChangeId, e.Reason); } } ``` ### API Key Authentication for SignalR ```csharp public class McpApiKeyAuthHandler : AuthenticationHandler { private readonly IMcpApiKeyService _apiKeyService; protected override async Task HandleAuthenticateAsync() { // For SignalR, API Key may be in query string var apiKey = Request.Query["access_token"].FirstOrDefault(); if (string.IsNullOrEmpty(apiKey)) { return AuthenticateResult.Fail("Missing API Key"); } var validationResult = await _apiKeyService.ValidateAsync(apiKey); if (!validationResult.IsValid) { return AuthenticateResult.Fail(validationResult.ErrorMessage); } // Store in HttpContext.Items for Hub access Context.Items["TenantId"] = validationResult.TenantId; Context.Items["ApiKeyId"] = validationResult.ApiKeyId; var claims = new[] { new Claim("TenantId", validationResult.TenantId.ToString()), new Claim("ApiKeyId", validationResult.ApiKeyId.ToString()) }; var identity = new ClaimsIdentity(claims, "ApiKey"); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, "ApiKey"); return AuthenticateResult.Success(ticket); } } ``` ### AI Client Example (Pseudo-code) ```typescript // AI Client (Claude Desktop / ChatGPT) const connection = new HubConnection("https://colaflow.com/hubs/mcp", { accessTokenFactory: () => apiKey }); await connection.start(); // Subscribe to pending change notifications await connection.invoke("SubscribeToPendingChange", pendingChangeId); // Listen for approval connection.on("PendingChangeApproved", (notification) => { console.log("Change approved:", notification); console.log("EntityId:", notification.EntityId); // Continue AI workflow... }); // Listen for rejection connection.on("PendingChangeRejected", (notification) => { console.log("Change rejected:", notification.Reason); // Adjust AI behavior... }); ``` ## Tasks ### Task 1: Create McpHub (2 hours) - [ ] Create `McpHub` class (inherit from `Hub`) - [ ] Implement `SubscribeToPendingChange()` method - [ ] Implement `UnsubscribeFromPendingChange()` method - [ ] Add connection lifecycle logging **Files to Create**: - `ColaFlow.Modules.Mcp/Hubs/McpHub.cs` ### Task 2: API Key Authentication for SignalR (3 hours) - [ ] Update `McpApiKeyMiddleware` to support query string API Key - [ ] Add API Key to `HttpContext.Items` for Hub access - [ ] Test SignalR connection with API Key **Files to Modify**: - `ColaFlow.Modules.Mcp/Middleware/McpApiKeyMiddleware.cs` ### Task 3: PendingChangeApproved Event Handler (2 hours) - [ ] Create `PendingChangeApprovedEventHandler` - [ ] Use `IHubContext` to send notification - [ ] Target specific group (`pending-change-{id}`) - [ ] Log notification sent **Files to Create**: - `ColaFlow.Modules.Mcp/EventHandlers/PendingChangeApprovedNotificationHandler.cs` ### Task 4: PendingChangeRejected Event Handler (1 hour) - [ ] Create `PendingChangeRejectedEventHandler` - [ ] Send rejection notification with reason **Files to Create**: - `ColaFlow.Modules.Mcp/EventHandlers/PendingChangeRejectedNotificationHandler.cs` ### Task 5: PendingChangeExpired Event Handler (1 hour) - [ ] Create `PendingChangeExpiredEventHandler` - [ ] Send expiration notification **Files to Create**: - `ColaFlow.Modules.Mcp/EventHandlers/PendingChangeExpiredNotificationHandler.cs` ### Task 6: Configure SignalR (1 hour) - [ ] Register `McpHub` in `Program.cs` - [ ] Configure CORS for SignalR - [ ] Configure authentication **Files to Modify**: - `ColaFlow.Api/Program.cs` ### Task 7: Integration Tests (4 hours) - [ ] Test SignalR connection with API Key - [ ] Test group subscription/unsubscription - [ ] Test notification delivery (approval/rejection) - [ ] Test multiple clients (isolation) **Files to Create**: - `ColaFlow.Modules.Mcp.Tests/Integration/McpHubIntegrationTests.cs` ## Testing Strategy ### Integration Test Example ```csharp [Fact] public async Task ApprovalNotification_SentToSubscribedClient() { // Arrange - Connect SignalR client var hubConnection = new HubConnectionBuilder() .WithUrl("http://localhost/hubs/mcp?access_token=" + _apiKey) .Build(); await hubConnection.StartAsync(); // Subscribe to pending change await hubConnection.InvokeAsync("SubscribeToPendingChange", _pendingChangeId); // Setup listener var notificationReceived = new TaskCompletionSource(); hubConnection.On("PendingChangeApproved", notification => { notificationReceived.SetResult(notification); }); // Act - Approve pending change await _pendingChangeService.ApproveAsync(_pendingChangeId, _userId, CancellationToken.None); // Assert - Notification received within 2 seconds var notification = await notificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.NotNull(notification); } ``` ## Dependencies **Prerequisites**: - Story 5.10 (PendingChange Management) - Domain events to listen to - M1 SignalR infrastructure (Day 11-17) - BaseHub, authentication **Optional**: Not strictly blocking for M2 MVP (can use polling fallback) ## Risks & Mitigation | Risk | Impact | Probability | Mitigation | |------|--------|-------------|------------| | WebSocket not supported | Medium | Low | Fallback to long polling | | SignalR connection drops | Medium | Medium | Auto-reconnect, message queue | | Scalability issues (many clients) | Medium | Low | Use Redis backplane for multi-instance | ## Definition of Done - [ ] McpHub implemented - [ ] API Key authentication working - [ ] Approval notification sent within 1 second - [ ] Rejection notification sent within 1 second - [ ] Group subscriptions working - [ ] Integration tests passing - [ ] Connection handling robust (disconnect/reconnect) - [ ] Code reviewed ## Notes ### Why This Story Matters - **Completes AI Loop**: AI can act on approval results immediately - **Better Performance**: WebSocket > polling (10x less overhead) - **Foundation**: Enables future real-time AI features - **M2 Polish**: Demonstrates production-grade implementation ### Key Design Decisions 1. **SignalR Groups**: Target specific pending changes (not broadcast) 2. **Domain Events**: Loosely coupled, easy to add more notifications 3. **API Key Auth**: Reuse existing authentication system 4. **Graceful Degradation**: Fallback to polling if WebSocket fails ### Scalability (Future) - Use Redis backplane for multi-instance SignalR - Horizontal scaling (multiple app servers) - Message queue for guaranteed delivery ### SignalR Configuration ```csharp // Program.cs builder.Services.AddSignalR() .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")); app.MapHub("/hubs/mcp"); ``` ## Reference Materials - SignalR Documentation: https://learn.microsoft.com/en-us/aspnet/core/signalr/introduction - Sprint 5 Plan: `docs/plans/sprint_5.md`