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,586 @@
---
story_id: story_5_3
sprint_id: sprint_5
phase: Phase 1 - Foundation
status: not_started
priority: P0
story_points: 5
assignee: backend
estimated_days: 2
created_date: 2025-11-06
dependencies: []
---
# 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
```csharp
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
```csharp
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
```csharp
// 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
```csharp
[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/`