Implemented JSON-RPC 2.0 protocol handler for MCP communication, enabling AI agents to communicate with ColaFlow using the Model Context Protocol. **Implementation:** - JSON-RPC 2.0 data models (Request, Response, Error, ErrorCode) - MCP protocol models (Initialize, Capabilities, ClientInfo, ServerInfo) - McpProtocolHandler with method routing and error handling - Method handlers: initialize, resources/list, tools/list, tools/call - ASP.NET Core middleware for /mcp endpoint - Service registration and dependency injection setup **Testing:** - 28 unit tests covering protocol parsing, validation, and error handling - Integration tests for initialize handshake and error responses - All tests passing with >80% coverage **Changes:** - Created ColaFlow.Modules.Mcp.Contracts project - Created ColaFlow.Modules.Mcp.Domain project - Created ColaFlow.Modules.Mcp.Application project - Created ColaFlow.Modules.Mcp.Infrastructure project - Created ColaFlow.Modules.Mcp.Tests project - Registered MCP module in ColaFlow.API Program.cs - Added /mcp endpoint via middleware **Acceptance Criteria Met:** ✅ JSON-RPC 2.0 messages correctly parsed ✅ Request validation (jsonrpc: "2.0", method, params, id) ✅ Error responses conform to JSON-RPC 2.0 spec ✅ Invalid requests return proper error codes (-32700, -32600, -32601, -32602) ✅ MCP initialize method implemented ✅ Server capabilities returned (resources, tools, prompts) ✅ Protocol version negotiation works (1.0) ✅ Request routing to method handlers ✅ Unit test coverage > 80% ✅ All tests passing **Story**: docs/stories/sprint_5/story_5_1.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
14 KiB
story_id, sprint_id, phase, status, priority, story_points, assignee, estimated_days, created_date, dependencies
| story_id | sprint_id | phase | status | priority | story_points | assignee | estimated_days | created_date | dependencies | ||
|---|---|---|---|---|---|---|---|---|---|---|---|
| story_5_10 | sprint_5 | Phase 3 - Tools & Diff Preview | not_started | P0 | 5 | backend | 2 | 2025-11-06 |
|
Story 5.10: PendingChange Management
Phase: Phase 3 - Tools & Diff Preview (Week 5-6) Priority: P0 CRITICAL Estimated Effort: 5 Story Points (2 days)
User Story
As a User I want to approve or reject AI-proposed changes So that I maintain control over my project data
Business Value
PendingChange management is the approval workflow for AI operations. It enables:
- Human-in-the-Loop: AI proposes, human approves
- Audit Trail: Complete history of all AI operations
- Safety: Prevents unauthorized or erroneous changes
- Compliance: Required for enterprise adoption
Acceptance Criteria
AC1: PendingChange CRUD
- Create PendingChange with Diff Preview
- Query PendingChanges (by tenant, by status, by user)
- Get PendingChange by ID
- Approve PendingChange (execute operation)
- Reject PendingChange (log reason)
AC2: Approval Workflow
- Approve action executes actual operation (create/update/delete)
- Approve action updates status to Approved
- Approve action logs approver and timestamp
- Reject action updates status to Rejected
- Reject action logs reason and rejecter
AC3: Auto-Expiration
- 24-hour expiration timer
- Background job checks expired changes
- Expired changes marked as Expired status
- Expired changes NOT executed
AC4: REST API Endpoints
GET /api/mcp/pending-changes- List (filter by status)GET /api/mcp/pending-changes/{id}- Get detailsPOST /api/mcp/pending-changes/{id}/approve- ApprovePOST /api/mcp/pending-changes/{id}/reject- Reject
AC5: Testing
- Unit tests for PendingChange service
- Integration tests for CRUD operations
- Integration tests for approval/rejection workflow
- Test auto-expiration mechanism
Technical Design
Service Interface
public interface IPendingChangeService
{
Task<PendingChange> CreateAsync(
string toolName,
DiffPreview diff,
CancellationToken cancellationToken);
Task<PendingChange?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken);
Task<PaginatedList<PendingChange>> GetPendingChangesAsync(
PendingChangeStatus? status,
int page,
int pageSize,
CancellationToken cancellationToken);
Task ApproveAsync(
Guid pendingChangeId,
Guid approvedBy,
CancellationToken cancellationToken);
Task RejectAsync(
Guid pendingChangeId,
Guid rejectedBy,
string reason,
CancellationToken cancellationToken);
Task ExpireOldChangesAsync(CancellationToken cancellationToken);
}
Service Implementation
public class PendingChangeService : IPendingChangeService
{
private readonly IPendingChangeRepository _repository;
private readonly ITenantContext _tenantContext;
private readonly IMediator _mediator;
private readonly ILogger<PendingChangeService> _logger;
public async Task<PendingChange> CreateAsync(
string toolName,
DiffPreview diff,
CancellationToken ct)
{
var tenantId = _tenantContext.CurrentTenantId;
var apiKeyId = (Guid)_httpContext.HttpContext.Items["ApiKeyId"]!;
var pendingChange = PendingChange.Create(
toolName, diff, tenantId, apiKeyId);
await _repository.AddAsync(pendingChange, ct);
await _repository.SaveChangesAsync(ct);
_logger.LogInformation(
"PendingChange created: {Id} - {ToolName} {Operation} {EntityType}",
pendingChange.Id, toolName, diff.Operation, diff.EntityType);
return pendingChange;
}
public async Task ApproveAsync(
Guid pendingChangeId,
Guid approvedBy,
CancellationToken ct)
{
var pendingChange = await _repository.GetByIdAsync(pendingChangeId, ct);
if (pendingChange == null)
throw new McpNotFoundException("PendingChange", pendingChangeId.ToString());
// Domain method (raises PendingChangeApprovedEvent)
pendingChange.Approve(approvedBy);
await _repository.UpdateAsync(pendingChange, ct);
await _repository.SaveChangesAsync(ct);
// Publish domain events (will trigger operation execution)
foreach (var domainEvent in pendingChange.DomainEvents)
{
await _mediator.Publish(domainEvent, ct);
}
_logger.LogInformation(
"PendingChange approved: {Id} by {ApprovedBy}",
pendingChangeId, approvedBy);
}
public async Task RejectAsync(
Guid pendingChangeId,
Guid rejectedBy,
string reason,
CancellationToken ct)
{
var pendingChange = await _repository.GetByIdAsync(pendingChangeId, ct);
if (pendingChange == null)
throw new McpNotFoundException("PendingChange", pendingChangeId.ToString());
pendingChange.Reject(rejectedBy, reason);
await _repository.UpdateAsync(pendingChange, ct);
await _repository.SaveChangesAsync(ct);
_logger.LogInformation(
"PendingChange rejected: {Id} by {RejectedBy} - Reason: {Reason}",
pendingChangeId, rejectedBy, reason);
}
public async Task ExpireOldChangesAsync(CancellationToken ct)
{
var expiredChanges = await _repository.GetExpiredPendingChangesAsync(ct);
foreach (var change in expiredChanges)
{
change.Expire();
await _repository.UpdateAsync(change, ct);
_logger.LogWarning(
"PendingChange expired: {Id} - {ToolName}",
change.Id, change.ToolName);
}
await _repository.SaveChangesAsync(ct);
_logger.LogInformation(
"Expired {Count} pending changes",
expiredChanges.Count);
}
}
Approval Event Handler (Executes Operation)
public class PendingChangeApprovedEventHandler
: INotificationHandler<PendingChangeApprovedEvent>
{
private readonly IMediator _mediator;
private readonly ILogger<PendingChangeApprovedEventHandler> _logger;
public async Task Handle(PendingChangeApprovedEvent e, CancellationToken ct)
{
var diff = e.Diff;
_logger.LogInformation(
"Executing approved operation: {Operation} {EntityType}",
diff.Operation, diff.EntityType);
try
{
// Route to appropriate command handler based on operation + entity type
object command = (diff.Operation, diff.EntityType) switch
{
("CREATE", "Story") => MapToCreateStoryCommand(diff),
("UPDATE", "Story") => MapToUpdateStoryCommand(diff),
("DELETE", "Story") => MapToDeleteStoryCommand(diff),
("CREATE", "Epic") => MapToCreateEpicCommand(diff),
("UPDATE", "Epic") => MapToUpdateEpicCommand(diff),
// ... more mappings
_ => throw new NotSupportedException(
$"Unsupported operation: {diff.Operation} {diff.EntityType}")
};
await _mediator.Send(command, ct);
_logger.LogInformation(
"Operation executed successfully: {PendingChangeId}",
e.PendingChangeId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to execute operation: {PendingChangeId}",
e.PendingChangeId);
// TODO: Update PendingChange status to ExecutionFailed
throw;
}
}
private CreateStoryCommand MapToCreateStoryCommand(DiffPreview diff)
{
var afterData = JsonSerializer.Deserialize<StoryDto>(
JsonSerializer.Serialize(diff.AfterData));
return new CreateStoryCommand
{
ProjectId = afterData.ProjectId,
Title = afterData.Title,
Description = afterData.Description,
Priority = afterData.Priority,
// ... map all fields
};
}
}
Background Job (Expiration Check)
public class PendingChangeExpirationJob : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<PendingChangeExpirationJob> _logger;
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
using var scope = _serviceProvider.CreateScope();
var pendingChangeService = scope.ServiceProvider
.GetRequiredService<IPendingChangeService>();
await pendingChangeService.ExpireOldChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PendingChange expiration job");
}
// Run every 5 minutes
await Task.Delay(TimeSpan.FromMinutes(5), ct);
}
}
}
Tasks
Task 1: PendingChangeRepository (3 hours)
- Create
IPendingChangeRepositoryinterface - Implement
PendingChangeRepository(EF Core) - CRUD methods (Add, Update, GetById, Query)
- GetExpiredPendingChangesAsync() method
Files to Create:
ColaFlow.Modules.Mcp.Infrastructure/Repositories/PendingChangeRepository.cs
Task 2: PendingChangeService (4 hours)
- Implement
CreateAsync() - Implement
ApproveAsync() - Implement
RejectAsync() - Implement
GetPendingChangesAsync()with pagination - Implement
ExpireOldChangesAsync()
Files to Create:
ColaFlow.Modules.Mcp.Application/Services/PendingChangeService.cs
Task 3: PendingChangeApprovedEventHandler (4 hours)
- Create event handler
- Map DiffPreview to CQRS commands
- Execute command via MediatR
- Error handling (log failures)
Files to Create:
ColaFlow.Modules.Mcp.Application/EventHandlers/PendingChangeApprovedEventHandler.cs
Task 4: REST API Controller (2 hours)
GET /api/mcp/pending-changesendpointGET /api/mcp/pending-changes/{id}endpointPOST /api/mcp/pending-changes/{id}/approveendpointPOST /api/mcp/pending-changes/{id}/rejectendpoint
Files to Create:
ColaFlow.Modules.Mcp/Controllers/PendingChangesController.cs
Task 5: Background Job (2 hours)
- Create
PendingChangeExpirationJob(BackgroundService) - Run every 5 minutes
- Call
ExpireOldChangesAsync() - Register in DI container
Files to Create:
ColaFlow.Modules.Mcp.Infrastructure/Jobs/PendingChangeExpirationJob.cs
Task 6: Unit Tests (4 hours)
- Test CreateAsync
- Test ApproveAsync (happy path)
- Test ApproveAsync (already approved throws exception)
- Test RejectAsync
- Test ExpireOldChangesAsync
Files to Create:
ColaFlow.Modules.Mcp.Tests/Services/PendingChangeServiceTests.cs
Task 7: Integration Tests (3 hours)
- Test full approval workflow (create → approve → verify execution)
- Test rejection workflow
- Test expiration (create → wait 24 hours → expire)
- Test multi-tenant isolation
Files to Create:
ColaFlow.Modules.Mcp.Tests/Integration/PendingChangeIntegrationTests.cs
Testing Strategy
Integration Test Workflow
[Fact]
public async Task ApprovalWorkflow_CreateStory_Success()
{
// Arrange
var diff = new DiffPreview(
operation: "CREATE",
entityType: "Story",
entityId: null,
entityKey: null,
beforeData: null,
afterData: new { title = "New Story", priority = "High" },
changedFields: new[] { /* ... */ }
);
// Act - Create PendingChange
var pendingChange = await _service.CreateAsync("create_issue", diff, CancellationToken.None);
Assert.Equal(PendingChangeStatus.PendingApproval, pendingChange.Status);
// Act - Approve
await _service.ApproveAsync(pendingChange.Id, _userId, CancellationToken.None);
// Assert - Verify Story created
var story = await _storyRepo.GetByIdAsync(/* ... */);
Assert.NotNull(story);
Assert.Equal("New Story", story.Title);
}
Dependencies
Prerequisites:
- Story 5.3 (MCP Domain Layer) - PendingChange aggregate
- Story 5.9 (Diff Preview Service) - DiffPreview value object
Used By:
- Story 5.11 (Core MCP Tools) - Creates PendingChange
- Story 5.12 (SignalR Notifications) - Notifies on approval/rejection
Risks & Mitigation
| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| Operation execution fails | Medium | Medium | Transaction rollback, error logging, retry mechanism |
| Expiration job stops | Medium | Low | Health checks, monitoring, alerting |
| Race condition (concurrent approval) | Low | Low | Optimistic concurrency, database constraints |
Definition of Done
- All CRUD operations working
- Approval executes operation correctly
- Rejection logs reason
- Expiration job running
- API endpoints working
- Unit tests passing (> 80%)
- Integration tests passing
- Code reviewed
Notes
Why This Story Matters
- Core M2 Feature: Enables human-in-the-loop AI operations
- Safety: Prevents AI mistakes
- Compliance: Audit trail required
- User Trust: Users control their data
Key Design Decisions
- Domain Events: Approval triggers execution (loose coupling)
- Background Job: Auto-expiration runs every 5 minutes
- 24-Hour TTL: Balance between user convenience and system cleanup
- Status Enum: Clear lifecycle (Pending → Approved/Rejected/Expired)