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>
18 KiB
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
DomainEventbase 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
AggregateRootbase class exists (from M1) - Verify
ValueObjectbase class exists (from M1) - Verify
DomainEventbase class exists (from M1) - Update if needed for MCP requirements
Files to Check/Update:
ColaFlow.Core/Domain/AggregateRoot.csColaFlow.Core/Domain/ValueObject.csColaFlow.Core/Domain/DomainEvent.cs
Task 2: PendingChange Aggregate Root (4 hours)
- Create
PendingChangeentity 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.csColaFlow.Modules.Mcp.Domain/Enums/PendingChangeStatus.cs
Task 3: DiffPreview Value Object (3 hours)
- Create
DiffPreviewvalue object - Create
DiffFieldvalue object - Implement equality comparison
- Add validation (operation, changed fields)
Files to Create:
ColaFlow.Modules.Mcp.Domain/ValueObjects/DiffPreview.csColaFlow.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.csColaFlow.Modules.Mcp.Domain/Events/PendingChangeApprovedEvent.csColaFlow.Modules.Mcp.Domain/Events/PendingChangeRejectedEvent.csColaFlow.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
DiffPreviewcreation - Test validation (operation required, entity type required)
- Test UPDATE requires changed fields
- Test equality comparison (value semantics)
- Test
DiffFieldcreation 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
- Aggregates: Consistency boundaries (McpApiKey, PendingChange)
- Value Objects: Immutable, equality by value (DiffPreview, DiffField)
- Domain Events: Side effects handled outside aggregate
- Factory Methods: Encapsulate creation logic, enforce invariants
- 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/