Files
ColaFlow/docs/stories/sprint_5/story_5_9.md
Yaojia Wang 48a8431e4f 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>
2025-11-07 19:38:34 +01:00

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_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

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

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