feat(backend): Implement MCP Protocol Handler (Story 5.1)
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>
This commit is contained in:
445
docs/stories/sprint_5/story_5_9.md
Normal file
445
docs/stories/sprint_5/story_5_9.md
Normal file
@@ -0,0 +1,445 @@
|
||||
---
|
||||
story_id: story_5_9
|
||||
sprint_id: sprint_5
|
||||
phase: Phase 3 - Tools & Diff Preview
|
||||
status: not_started
|
||||
priority: P0
|
||||
story_points: 5
|
||||
assignee: backend
|
||||
estimated_days: 2
|
||||
created_date: 2025-11-06
|
||||
dependencies: [story_5_3]
|
||||
---
|
||||
|
||||
# Story 5.9: Diff Preview Service Implementation
|
||||
|
||||
**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 see what changes AI will make before they are applied
|
||||
**So that** I can approve or reject AI operations safely
|
||||
|
||||
## Business Value
|
||||
|
||||
Diff Preview is the **core safety mechanism** for M2. It enables:
|
||||
- **Transparency**: Users see exactly what will change
|
||||
- **Safety**: Prevents AI mistakes from affecting production data
|
||||
- **Compliance**: Audit trail for all AI operations
|
||||
- **Trust**: Users confident in AI automation
|
||||
|
||||
**Without Diff Preview, AI write operations would be too risky for production.**
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC1: Diff Preview Generation
|
||||
- [ ] Generate before/after snapshots for CREATE, UPDATE, DELETE
|
||||
- [ ] Calculate field-level differences (changed fields only)
|
||||
- [ ] Support complex objects (nested, arrays, JSON)
|
||||
- [ ] Handle null values and type changes
|
||||
|
||||
### AC2: Diff Output Format
|
||||
- [ ] JSON format with before/after data
|
||||
- [ ] Array of changed fields (field name, old value, new value)
|
||||
- [ ] HTML diff for text fields (visual diff)
|
||||
- [ ] Entity metadata (type, ID, key)
|
||||
|
||||
### AC3: Supported Operations
|
||||
- [ ] CREATE: Show all new fields (before = null, after = new data)
|
||||
- [ ] UPDATE: Show only changed fields (before vs after)
|
||||
- [ ] DELETE: Show all deleted fields (before = data, after = null)
|
||||
|
||||
### AC4: Edge Cases
|
||||
- [ ] Nested object changes (e.g., assignee change)
|
||||
- [ ] Array/list changes (e.g., add/remove tags)
|
||||
- [ ] Date/time formatting
|
||||
- [ ] Large text fields (truncate in preview)
|
||||
|
||||
### AC5: Testing
|
||||
- [ ] Unit tests for all operations (CREATE, UPDATE, DELETE)
|
||||
- [ ] Test complex object changes
|
||||
- [ ] Test edge cases (null, arrays, nested)
|
||||
- [ ] Performance test (< 50ms for typical diff)
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Service Interface
|
||||
|
||||
```csharp
|
||||
public interface IDiffPreviewService
|
||||
{
|
||||
Task<DiffPreview> GeneratePreviewAsync<TEntity>(
|
||||
Guid? entityId,
|
||||
TEntity afterData,
|
||||
string operation,
|
||||
CancellationToken cancellationToken) where TEntity : class;
|
||||
}
|
||||
|
||||
public class DiffPreviewService : IDiffPreviewService
|
||||
{
|
||||
private readonly IGenericRepository _repository;
|
||||
private readonly ILogger<DiffPreviewService> _logger;
|
||||
|
||||
public async Task<DiffPreview> GeneratePreviewAsync<TEntity>(
|
||||
Guid? entityId,
|
||||
TEntity afterData,
|
||||
string operation,
|
||||
CancellationToken ct) where TEntity : class
|
||||
{
|
||||
switch (operation)
|
||||
{
|
||||
case "CREATE":
|
||||
return GenerateCreatePreview(afterData);
|
||||
|
||||
case "UPDATE":
|
||||
if (!entityId.HasValue)
|
||||
throw new ArgumentException("EntityId required for UPDATE");
|
||||
|
||||
var beforeData = await _repository.GetByIdAsync<TEntity>(entityId.Value, ct);
|
||||
if (beforeData == null)
|
||||
throw new McpNotFoundException(typeof(TEntity).Name, entityId.Value.ToString());
|
||||
|
||||
return GenerateUpdatePreview(entityId.Value, beforeData, afterData);
|
||||
|
||||
case "DELETE":
|
||||
if (!entityId.HasValue)
|
||||
throw new ArgumentException("EntityId required for DELETE");
|
||||
|
||||
var dataToDelete = await _repository.GetByIdAsync<TEntity>(entityId.Value, ct);
|
||||
if (dataToDelete == null)
|
||||
throw new McpNotFoundException(typeof(TEntity).Name, entityId.Value.ToString());
|
||||
|
||||
return GenerateDeletePreview(entityId.Value, dataToDelete);
|
||||
|
||||
default:
|
||||
throw new ArgumentException($"Unknown operation: {operation}");
|
||||
}
|
||||
}
|
||||
|
||||
private DiffPreview GenerateCreatePreview<TEntity>(TEntity afterData)
|
||||
{
|
||||
var changedFields = new List<DiffField>();
|
||||
var properties = typeof(TEntity).GetProperties();
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var newValue = prop.GetValue(afterData);
|
||||
if (newValue != null) // Skip null fields
|
||||
{
|
||||
changedFields.Add(new DiffField(
|
||||
fieldName: prop.Name,
|
||||
displayName: FormatFieldName(prop.Name),
|
||||
oldValue: null,
|
||||
newValue: newValue
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new DiffPreview(
|
||||
operation: "CREATE",
|
||||
entityType: typeof(TEntity).Name,
|
||||
entityId: null,
|
||||
entityKey: null,
|
||||
beforeData: null,
|
||||
afterData: afterData,
|
||||
changedFields: changedFields.AsReadOnly()
|
||||
);
|
||||
}
|
||||
|
||||
private DiffPreview GenerateUpdatePreview<TEntity>(
|
||||
Guid entityId,
|
||||
TEntity beforeData,
|
||||
TEntity afterData)
|
||||
{
|
||||
var changedFields = new List<DiffField>();
|
||||
var properties = typeof(TEntity).GetProperties();
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var oldValue = prop.GetValue(beforeData);
|
||||
var newValue = prop.GetValue(afterData);
|
||||
|
||||
if (!Equals(oldValue, newValue))
|
||||
{
|
||||
var diffHtml = GenerateHtmlDiff(oldValue, newValue, prop.PropertyType);
|
||||
|
||||
changedFields.Add(new DiffField(
|
||||
fieldName: prop.Name,
|
||||
displayName: FormatFieldName(prop.Name),
|
||||
oldValue: oldValue,
|
||||
newValue: newValue,
|
||||
diffHtml: diffHtml
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new DiffPreview(
|
||||
operation: "UPDATE",
|
||||
entityType: typeof(TEntity).Name,
|
||||
entityId: entityId,
|
||||
entityKey: GetEntityKey(afterData),
|
||||
beforeData: beforeData,
|
||||
afterData: afterData,
|
||||
changedFields: changedFields.AsReadOnly()
|
||||
);
|
||||
}
|
||||
|
||||
private DiffPreview GenerateDeletePreview<TEntity>(Guid entityId, TEntity dataToDelete)
|
||||
{
|
||||
var changedFields = new List<DiffField>();
|
||||
var properties = typeof(TEntity).GetProperties();
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
var oldValue = prop.GetValue(dataToDelete);
|
||||
if (oldValue != null)
|
||||
{
|
||||
changedFields.Add(new DiffField(
|
||||
fieldName: prop.Name,
|
||||
displayName: FormatFieldName(prop.Name),
|
||||
oldValue: oldValue,
|
||||
newValue: null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new DiffPreview(
|
||||
operation: "DELETE",
|
||||
entityType: typeof(TEntity).Name,
|
||||
entityId: entityId,
|
||||
entityKey: GetEntityKey(dataToDelete),
|
||||
beforeData: dataToDelete,
|
||||
afterData: null,
|
||||
changedFields: changedFields.AsReadOnly()
|
||||
);
|
||||
}
|
||||
|
||||
private string? GenerateHtmlDiff(object? oldValue, object? newValue, Type propertyType)
|
||||
{
|
||||
// For string properties, generate HTML diff
|
||||
if (propertyType == typeof(string) && oldValue != null && newValue != null)
|
||||
{
|
||||
var oldStr = oldValue.ToString();
|
||||
var newStr = newValue.ToString();
|
||||
|
||||
// Use simple diff algorithm (can be improved with DiffPlex library)
|
||||
return $"<del>{oldStr}</del> <ins>{newStr}</ins>";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string FormatFieldName(string fieldName)
|
||||
{
|
||||
// Convert "EstimatedHours" to "Estimated Hours"
|
||||
return System.Text.RegularExpressions.Regex.Replace(
|
||||
fieldName, "([a-z])([A-Z])", "$1 $2");
|
||||
}
|
||||
|
||||
private string? GetEntityKey<TEntity>(TEntity entity)
|
||||
{
|
||||
// Try to get a human-readable key (e.g., "COLA-146")
|
||||
var keyProp = typeof(TEntity).GetProperty("Key");
|
||||
return keyProp?.GetValue(entity)?.ToString();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Diff Output (UPDATE Issue)
|
||||
|
||||
```json
|
||||
{
|
||||
"operation": "UPDATE",
|
||||
"entityType": "Story",
|
||||
"entityId": "12345678-1234-1234-1234-123456789012",
|
||||
"entityKey": "COLA-146",
|
||||
"beforeData": {
|
||||
"title": "Implement MCP Server",
|
||||
"priority": "High",
|
||||
"status": "InProgress",
|
||||
"assigneeId": "aaaa-bbbb-cccc-dddd"
|
||||
},
|
||||
"afterData": {
|
||||
"title": "Implement MCP Server (updated)",
|
||||
"priority": "Critical",
|
||||
"status": "InProgress",
|
||||
"assigneeId": "aaaa-bbbb-cccc-dddd"
|
||||
},
|
||||
"changedFields": [
|
||||
{
|
||||
"fieldName": "title",
|
||||
"displayName": "Title",
|
||||
"oldValue": "Implement MCP Server",
|
||||
"newValue": "Implement MCP Server (updated)",
|
||||
"diffHtml": "<del>Implement MCP Server</del> <ins>Implement MCP Server (updated)</ins>"
|
||||
},
|
||||
{
|
||||
"fieldName": "priority",
|
||||
"displayName": "Priority",
|
||||
"oldValue": "High",
|
||||
"newValue": "Critical"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create DiffPreviewService Interface (1 hour)
|
||||
- [ ] Define `IDiffPreviewService` interface
|
||||
- [ ] Define method signature (generic TEntity)
|
||||
|
||||
**Files to Create**:
|
||||
- `ColaFlow.Modules.Mcp.Application/Contracts/IDiffPreviewService.cs`
|
||||
|
||||
### Task 2: Implement CREATE Preview (2 hours)
|
||||
- [ ] Extract all non-null properties from afterData
|
||||
- [ ] Format field names (camelCase → Title Case)
|
||||
- [ ] Return DiffPreview with changedFields
|
||||
|
||||
**Files to Create**:
|
||||
- `ColaFlow.Modules.Mcp.Application/Services/DiffPreviewService.cs`
|
||||
|
||||
### Task 3: Implement UPDATE Preview (4 hours)
|
||||
- [ ] Fetch beforeData from repository
|
||||
- [ ] Compare all properties (old vs new)
|
||||
- [ ] Build changedFields list (only changed)
|
||||
- [ ] Generate HTML diff for text fields
|
||||
|
||||
### Task 4: Implement DELETE Preview (1 hour)
|
||||
- [ ] Fetch dataToDelete from repository
|
||||
- [ ] Extract all non-null properties
|
||||
- [ ] Return DiffPreview with oldValue set, newValue null
|
||||
|
||||
### Task 5: Handle Complex Types (3 hours)
|
||||
- [ ] Nested objects (e.g., assignee change)
|
||||
- [ ] Arrays/lists (e.g., tags)
|
||||
- [ ] Date/time formatting
|
||||
- [ ] Large text truncation
|
||||
|
||||
### Task 6: Unit Tests (5 hours)
|
||||
- [ ] Test CREATE preview (all fields)
|
||||
- [ ] Test UPDATE preview (only changed fields)
|
||||
- [ ] Test DELETE preview (all fields)
|
||||
- [ ] Test nested object changes
|
||||
- [ ] Test array changes
|
||||
- [ ] Test null handling
|
||||
|
||||
**Files to Create**:
|
||||
- `ColaFlow.Modules.Mcp.Tests/Services/DiffPreviewServiceTests.cs`
|
||||
|
||||
### Task 7: Integration Tests (2 hours)
|
||||
- [ ] Test with real entities (Story, Epic, WorkTask)
|
||||
- [ ] Test performance (< 50ms)
|
||||
|
||||
**Files to Create**:
|
||||
- `ColaFlow.Modules.Mcp.Tests/Integration/DiffPreviewIntegrationTests.cs`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Target: > 90% coverage)
|
||||
- All operations (CREATE, UPDATE, DELETE)
|
||||
- All data types (string, int, enum, nested object, array)
|
||||
- Edge cases (null, empty, large text)
|
||||
|
||||
### Test Cases
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task GeneratePreview_CreateOperation_AllFieldsIncluded()
|
||||
{
|
||||
// Arrange
|
||||
var newIssue = new Story
|
||||
{
|
||||
Title = "New Story",
|
||||
Priority = Priority.High,
|
||||
Status = IssueStatus.Todo
|
||||
};
|
||||
|
||||
// Act
|
||||
var diff = await _service.GeneratePreviewAsync(
|
||||
null, newIssue, "CREATE", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CREATE", diff.Operation);
|
||||
Assert.Null(diff.EntityId);
|
||||
Assert.Null(diff.BeforeData);
|
||||
Assert.NotNull(diff.AfterData);
|
||||
Assert.Equal(3, diff.ChangedFields.Count); // Title, Priority, Status
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePreview_UpdateOperation_OnlyChangedFields()
|
||||
{
|
||||
// Arrange
|
||||
var existingIssue = new Story
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Original Title",
|
||||
Priority = Priority.Medium,
|
||||
Status = IssueStatus.InProgress
|
||||
};
|
||||
|
||||
var updatedIssue = new Story
|
||||
{
|
||||
Id = existingIssue.Id,
|
||||
Title = "Updated Title",
|
||||
Priority = Priority.High, // Changed
|
||||
Status = IssueStatus.InProgress // Unchanged
|
||||
};
|
||||
|
||||
_mockRepo.Setup(r => r.GetByIdAsync<Story>(existingIssue.Id, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(existingIssue);
|
||||
|
||||
// Act
|
||||
var diff = await _service.GeneratePreviewAsync(
|
||||
existingIssue.Id, updatedIssue, "UPDATE", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("UPDATE", diff.Operation);
|
||||
Assert.Equal(2, diff.ChangedFields.Count); // Title, Priority (Status unchanged)
|
||||
Assert.Contains(diff.ChangedFields, f => f.FieldName == "Title");
|
||||
Assert.Contains(diff.ChangedFields, f => f.FieldName == "Priority");
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Prerequisites**:
|
||||
- Story 5.3 (MCP Domain Layer) - Uses DiffPreview value object
|
||||
|
||||
**Used By**:
|
||||
- Story 5.11 (Core MCP Tools) - Generates diff before creating PendingChange
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Probability | Mitigation |
|
||||
|------|--------|-------------|------------|
|
||||
| Complex object diff bugs | Medium | Medium | Comprehensive unit tests, code review |
|
||||
| Performance slow (> 50ms) | Medium | Low | Optimize reflection, cache property info |
|
||||
| Large text diff memory | Low | Low | Truncate long text fields |
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] All 3 operations (CREATE, UPDATE, DELETE) working
|
||||
- [ ] Unit test coverage > 90%
|
||||
- [ ] Integration tests passing
|
||||
- [ ] Performance < 50ms for typical diff
|
||||
- [ ] Code reviewed and approved
|
||||
- [ ] Handles complex types (nested, arrays)
|
||||
|
||||
## Notes
|
||||
|
||||
### Why This Story Matters
|
||||
- **Core Safety Mechanism**: Without diff preview, AI operations too risky
|
||||
- **User Trust**: Transparency builds confidence in AI
|
||||
- **Compliance**: Required audit trail
|
||||
- **M2 Critical Path**: Blocks all write operations
|
||||
|
||||
### Performance Optimization
|
||||
- Cache property reflection info
|
||||
- Use source generators for serialization (future)
|
||||
- Limit max diff size (truncate large fields)
|
||||
Reference in New Issue
Block a user