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>
13 KiB
13 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_9 | sprint_5 | Phase 3 - Tools & Diff Preview | not_started | P0 | 5 | backend | 2 | 2025-11-06 |
|
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
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)
{
"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
IDiffPreviewServiceinterface - 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
[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)