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:
Yaojia Wang
2025-11-07 19:38:34 +01:00
parent d3ef2c1441
commit 48a8431e4f
43 changed files with 7003 additions and 0 deletions

View 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)