Files
ColaFlow/docs/stories/sprint_5/story_5_3.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

18 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_3 sprint_5 Phase 1 - Foundation not_started P0 5 backend 2 2025-11-06

Story 5.3: MCP Domain Layer Design

Phase: Phase 1 - Foundation (Week 1-2) Priority: P0 CRITICAL Estimated Effort: 5 Story Points (2 days)

User Story

As a Backend Developer I want well-designed domain entities and aggregates for MCP operations So that the system has a solid foundation following DDD principles and can be easily extended

Business Value

Clean domain design is critical for long-term maintainability. This Story establishes the domain model for all MCP operations, ensuring business rules are enforced consistently and the system can evolve without accumulating technical debt.

Impact:

  • Provides solid foundation for all MCP features
  • Enforces business rules at domain level
  • Enables rich domain events for audit and notifications
  • Reduces bugs through domain-driven validation

Acceptance Criteria

AC1: McpApiKey Aggregate Root (Covered in Story 5.2)

  • McpApiKey entity implemented as aggregate root
  • Factory method for creation with validation
  • Domain methods (Revoke, RecordUsage, UpdatePermissions)
  • Domain events (ApiKeyCreated, ApiKeyRevoked)

AC2: PendingChange Aggregate Root

  • PendingChange entity with status lifecycle
  • Factory method for creation from Diff Preview
  • Approve() method that triggers execution
  • Reject() method with reason logging
  • Expire() method for 24-hour timeout
  • Domain events (PendingChangeCreated, Approved, Rejected, Expired)

AC3: DiffPreview Value Object

  • Immutable value object for before/after data
  • Field-level change tracking (DiffField collection)
  • Support for CREATE, UPDATE, DELETE operations
  • JSON serialization for complex objects
  • Equality comparison (value semantics)

AC4: Domain Events

  • ApiKeyCreatedEvent
  • ApiKeyRevokedEvent
  • PendingChangeCreatedEvent
  • PendingChangeApprovedEvent
  • PendingChangeRejectedEvent
  • PendingChangeExpiredEvent
  • All events inherit from DomainEvent base class

AC5: Business Rule Validation

  • PendingChange cannot be approved if expired
  • PendingChange cannot be rejected if already approved
  • McpApiKey cannot be used if revoked or expired
  • DiffPreview must have at least one changed field for UPDATE
  • All invariants enforced in domain entities

AC6: Testing

  • Unit test coverage > 90% for domain layer
  • Test all domain methods (Create, Approve, Reject, etc.)
  • Test domain events are raised correctly
  • Test business rule violations throw exceptions

Technical Design

Domain Model Diagram

┌─────────────────────────────────────┐
│      McpApiKey (Aggregate Root)     │
│  - TenantId                         │
│  - KeyHash, KeyPrefix               │
│  - Permissions                      │
│  - Status (Active, Revoked)         │
│  - ExpiresAt                        │
│  + Create()                         │
│  + Revoke()                         │
│  + RecordUsage()                    │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│    PendingChange (Aggregate Root)   │
│  - TenantId, ApiKeyId               │
│  - ToolName                         │
│  - Diff (DiffPreview)               │
│  - Status (Pending, Approved, ...)  │
│  - ExpiresAt                        │
│  + Create()                         │
│  + Approve()                        │
│  + Reject()                         │
│  + Expire()                         │
└─────────────────────────────────────┘
                │
                │ has-a
                ↓
┌─────────────────────────────────────┐
│     DiffPreview (Value Object)      │
│  - Operation (CREATE/UPDATE/DELETE) │
│  - EntityType, EntityId             │
│  - BeforeData, AfterData            │
│  - ChangedFields[]                  │
│  + CalculateDiff()                  │
└─────────────────────────────────────┘

PendingChange Aggregate Root

public class PendingChange : AggregateRoot
{
    public Guid TenantId { get; private set; }
    public Guid ApiKeyId { get; private set; }

    public string ToolName { get; private set; }
    public DiffPreview Diff { get; private set; }

    public PendingChangeStatus Status { get; private set; }
    public DateTime ExpiresAt { get; private set; }

    public Guid? ApprovedBy { get; private set; }
    public DateTime? ApprovedAt { get; private set; }

    public Guid? RejectedBy { get; private set; }
    public DateTime? RejectedAt { get; private set; }
    public string? RejectionReason { get; private set; }

    // Factory method
    public static PendingChange Create(
        string toolName,
        DiffPreview diff,
        Guid tenantId,
        Guid apiKeyId)
    {
        if (string.IsNullOrWhiteSpace(toolName))
            throw new ArgumentException("Tool name required", nameof(toolName));

        if (diff == null)
            throw new ArgumentNullException(nameof(diff));

        var pendingChange = new PendingChange
        {
            Id = Guid.NewGuid(),
            TenantId = tenantId,
            ApiKeyId = apiKeyId,
            ToolName = toolName,
            Diff = diff,
            Status = PendingChangeStatus.PendingApproval,
            ExpiresAt = DateTime.UtcNow.AddHours(24)
        };

        pendingChange.AddDomainEvent(new PendingChangeCreatedEvent(
            pendingChange.Id,
            toolName,
            diff.EntityType,
            diff.Operation
        ));

        return pendingChange;
    }

    // Domain method: Approve
    public void Approve(Guid approvedBy)
    {
        if (Status != PendingChangeStatus.PendingApproval)
            throw new InvalidOperationException(
                $"Cannot approve change with status {Status}");

        if (DateTime.UtcNow > ExpiresAt)
            throw new InvalidOperationException(
                "Cannot approve expired change");

        Status = PendingChangeStatus.Approved;
        ApprovedBy = approvedBy;
        ApprovedAt = DateTime.UtcNow;

        AddDomainEvent(new PendingChangeApprovedEvent(
            Id,
            ToolName,
            Diff,
            approvedBy
        ));
    }

    // Domain method: Reject
    public void Reject(Guid rejectedBy, string reason)
    {
        if (Status != PendingChangeStatus.PendingApproval)
            throw new InvalidOperationException(
                $"Cannot reject change with status {Status}");

        Status = PendingChangeStatus.Rejected;
        RejectedBy = rejectedBy;
        RejectedAt = DateTime.UtcNow;
        RejectionReason = reason;

        AddDomainEvent(new PendingChangeRejectedEvent(
            Id,
            ToolName,
            reason,
            rejectedBy
        ));
    }

    // Domain method: Expire
    public void Expire()
    {
        if (Status != PendingChangeStatus.PendingApproval)
            return; // Already processed

        if (DateTime.UtcNow <= ExpiresAt)
            throw new InvalidOperationException(
                "Cannot expire change before expiration time");

        Status = PendingChangeStatus.Expired;

        AddDomainEvent(new PendingChangeExpiredEvent(Id, ToolName));
    }
}

public enum PendingChangeStatus
{
    PendingApproval = 0,
    Approved = 1,
    Rejected = 2,
    Expired = 3
}

DiffPreview Value Object

public class DiffPreview : ValueObject
{
    public string Operation { get; private set; } // CREATE, UPDATE, DELETE
    public string EntityType { get; private set; }
    public Guid? EntityId { get; private set; }
    public string? EntityKey { get; private set; } // e.g., "COLA-146"

    public object? BeforeData { get; private set; }
    public object? AfterData { get; private set; }

    public IReadOnlyList<DiffField> ChangedFields { get; private set; }

    public DiffPreview(
        string operation,
        string entityType,
        Guid? entityId,
        string? entityKey,
        object? beforeData,
        object? afterData,
        IReadOnlyList<DiffField> changedFields)
    {
        if (string.IsNullOrWhiteSpace(operation))
            throw new ArgumentException("Operation required", nameof(operation));

        if (string.IsNullOrWhiteSpace(entityType))
            throw new ArgumentException("EntityType required", nameof(entityType));

        if (operation == "UPDATE" && (changedFields == null || !changedFields.Any()))
            throw new ArgumentException(
                "UPDATE operation must have at least one changed field",
                nameof(changedFields));

        Operation = operation;
        EntityType = entityType;
        EntityId = entityId;
        EntityKey = entityKey;
        BeforeData = beforeData;
        AfterData = afterData;
        ChangedFields = changedFields ?? new List<DiffField>().AsReadOnly();
    }

    // Value object equality
    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Operation;
        yield return EntityType;
        yield return EntityId;
        yield return EntityKey;
        yield return BeforeData;
        yield return AfterData;
        foreach (var field in ChangedFields)
            yield return field;
    }
}

public class DiffField : ValueObject
{
    public string FieldName { get; private set; }
    public string DisplayName { get; private set; }
    public object? OldValue { get; private set; }
    public object? NewValue { get; private set; }
    public string? DiffHtml { get; private set; }

    public DiffField(
        string fieldName,
        string displayName,
        object? oldValue,
        object? newValue,
        string? diffHtml = null)
    {
        FieldName = fieldName;
        DisplayName = displayName;
        OldValue = oldValue;
        NewValue = newValue;
        DiffHtml = diffHtml;
    }

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return FieldName;
        yield return DisplayName;
        yield return OldValue;
        yield return NewValue;
        yield return DiffHtml;
    }
}

Domain Events

// Base class (should already exist in M1)
public abstract record DomainEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
}

// PendingChange events
public record PendingChangeCreatedEvent(
    Guid PendingChangeId,
    string ToolName,
    string EntityType,
    string Operation
) : DomainEvent;

public record PendingChangeApprovedEvent(
    Guid PendingChangeId,
    string ToolName,
    DiffPreview Diff,
    Guid ApprovedBy
) : DomainEvent;

public record PendingChangeRejectedEvent(
    Guid PendingChangeId,
    string ToolName,
    string Reason,
    Guid RejectedBy
) : DomainEvent;

public record PendingChangeExpiredEvent(
    Guid PendingChangeId,
    string ToolName
) : DomainEvent;

// ApiKey events (from Story 5.2)
public record ApiKeyCreatedEvent(
    Guid ApiKeyId,
    string Name
) : DomainEvent;

public record ApiKeyRevokedEvent(
    Guid ApiKeyId,
    string Name
) : DomainEvent;

Tasks

Task 1: Create Domain Base Classes (2 hours)

  • Verify AggregateRoot base class exists (from M1)
  • Verify ValueObject base class exists (from M1)
  • Verify DomainEvent base class exists (from M1)
  • Update if needed for MCP requirements

Files to Check/Update:

  • ColaFlow.Core/Domain/AggregateRoot.cs
  • ColaFlow.Core/Domain/ValueObject.cs
  • ColaFlow.Core/Domain/DomainEvent.cs

Task 2: PendingChange Aggregate Root (4 hours)

  • Create PendingChange entity class
  • Implement Create() factory method
  • Implement Approve() domain method
  • Implement Reject() domain method
  • Implement Expire() domain method
  • Add business rule validation

Files to Create:

  • ColaFlow.Modules.Mcp.Domain/Entities/PendingChange.cs
  • ColaFlow.Modules.Mcp.Domain/Enums/PendingChangeStatus.cs

Task 3: DiffPreview Value Object (3 hours)

  • Create DiffPreview value object
  • Create DiffField value object
  • Implement equality comparison
  • Add validation (operation, changed fields)

Files to Create:

  • ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.cs
  • ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffField.cs

Task 4: Domain Events (2 hours)

  • Create PendingChangeCreatedEvent
  • Create PendingChangeApprovedEvent
  • Create PendingChangeRejectedEvent
  • Create PendingChangeExpiredEvent
  • Ensure events inherit from DomainEvent

Files to Create:

  • ColaFlow.Modules.Mcp.Domain/Events/PendingChangeCreatedEvent.cs
  • ColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.cs
  • ColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.cs
  • ColaFlow.Modules.Mcp.Domain/Events/PendingChangeExpiredEvent.cs

Task 5: Unit Tests - PendingChange (4 hours)

  • Test Create() factory method
  • Test Approve() happy path
  • Test Approve() when already approved (throws exception)
  • Test Approve() when expired (throws exception)
  • Test Reject() happy path
  • Test Reject() when already approved (throws exception)
  • Test Expire() happy path
  • Test domain events are raised

Files to Create:

  • ColaFlow.Modules.Mcp.Tests/Domain/PendingChangeTests.cs

Task 6: Unit Tests - DiffPreview (3 hours)

  • Test DiffPreview creation
  • Test validation (operation required, entity type required)
  • Test UPDATE requires changed fields
  • Test equality comparison (value semantics)
  • Test DiffField creation and equality

Files to Create:

  • ColaFlow.Modules.Mcp.Tests/Domain/DiffPreviewTests.cs

Testing Strategy

Unit Tests (Target: > 90% coverage)

  • All domain entity methods (Create, Approve, Reject, Expire)
  • All business rule validations
  • Domain events raised at correct times
  • Value object equality semantics
  • Edge cases (null values, invalid states)

Test Cases for PendingChange

[Fact]
public void Create_ValidInput_Success()
{
    // Arrange
    var diff = CreateValidDiff();

    // Act
    var pendingChange = PendingChange.Create(
        "create_issue", diff, tenantId, apiKeyId);

    // Assert
    Assert.NotEqual(Guid.Empty, pendingChange.Id);
    Assert.Equal(PendingChangeStatus.PendingApproval, pendingChange.Status);
    Assert.True(pendingChange.ExpiresAt > DateTime.UtcNow);
    Assert.Single(pendingChange.DomainEvents); // PendingChangeCreatedEvent
}

[Fact]
public void Approve_PendingChange_Success()
{
    // Arrange
    var pendingChange = CreatePendingChange();
    var approverId = Guid.NewGuid();

    // Act
    pendingChange.Approve(approverId);

    // Assert
    Assert.Equal(PendingChangeStatus.Approved, pendingChange.Status);
    Assert.Equal(approverId, pendingChange.ApprovedBy);
    Assert.NotNull(pendingChange.ApprovedAt);
    Assert.Contains(pendingChange.DomainEvents,
        e => e is PendingChangeApprovedEvent);
}

[Fact]
public void Approve_AlreadyApproved_ThrowsException()
{
    // Arrange
    var pendingChange = CreatePendingChange();
    pendingChange.Approve(Guid.NewGuid());

    // Act & Assert
    Assert.Throws<InvalidOperationException>(
        () => pendingChange.Approve(Guid.NewGuid()));
}

[Fact]
public void Approve_ExpiredChange_ThrowsException()
{
    // Arrange
    var pendingChange = CreateExpiredPendingChange();

    // Act & Assert
    Assert.Throws<InvalidOperationException>(
        () => pendingChange.Approve(Guid.NewGuid()));
}

Dependencies

Prerequisites:

  • M1 domain base classes (AggregateRoot, ValueObject, DomainEvent)
  • .NET 9

Used By:

  • Story 5.9 (Diff Preview Service) - Uses DiffPreview value object
  • Story 5.10 (PendingChange Management) - Uses PendingChange aggregate
  • Story 5.11 (Core MCP Tools) - Creates PendingChange entities

Risks & Mitigation

Risk Impact Probability Mitigation
Business rules incomplete Medium Medium Comprehensive unit tests, domain expert review
Domain events not raised Medium Low Unit tests verify events, event sourcing pattern
Value object equality bugs Low Low Thorough testing, use proven patterns
Over-engineering domain Medium Medium Keep it simple, add complexity only when needed

Definition of Done

  • Code compiles without warnings
  • All unit tests passing (> 90% coverage)
  • Code reviewed and approved
  • XML documentation for all public APIs
  • All aggregates follow DDD patterns
  • All value objects are immutable
  • All domain events inherit from DomainEvent
  • Business rules enforced in domain entities (not services)
  • No anemic domain model (rich behavior in entities)

Notes

Why This Story Matters

  • Clean Architecture: Solid domain foundation prevents future refactoring
  • Business Rules: Domain entities enforce invariants, reducing bugs
  • Domain Events: Enable loose coupling, audit trail, notifications
  • Testability: Rich domain model is easy to unit test

Key Design Principles

  1. Aggregates: Consistency boundaries (McpApiKey, PendingChange)
  2. Value Objects: Immutable, equality by value (DiffPreview, DiffField)
  3. Domain Events: Side effects handled outside aggregate
  4. Factory Methods: Encapsulate creation logic, enforce invariants
  5. Encapsulation: Private setters, public domain methods

DDD Patterns Used

  • Aggregate Root: PendingChange, McpApiKey
  • Value Object: DiffPreview, DiffField
  • Domain Event: 6 events for audit and notifications
  • Factory Method: Create() methods with validation
  • Invariant Protection: Business rules in domain entities

Reference Materials

  • Domain-Driven Design (Eric Evans)
  • Sprint 5 Plan: docs/plans/sprint_5.md
  • Architecture Design: docs/M2-MCP-SERVER-ARCHITECTURE.md
  • M1 Domain Layer: ColaFlow.Core/Domain/