feat(backend): Add CreateProjectSdkTool for MCP SDK

Adds a new MCP SDK tool that allows AI to create projects in ColaFlow.
The tool creates pending changes requiring human approval.

Features:
- Validates project name (max 100 chars)
- Validates project key (2-10 uppercase letters, unique)
- Validates description (max 500 chars)
- Checks for duplicate project keys
- Generates diff preview for human approval
- Retrieves owner ID from authentication context (JWT or API key)

🤖 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-23 15:36:36 +01:00
parent a55006b810
commit 9f774b56b0

View File

@@ -0,0 +1,167 @@
using System.ComponentModel;
using System.Security.Claims;
using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.Mcp.Domain.Services;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: create_project (SDK-based implementation)
/// Creates a new Project in ColaFlow
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class CreateProjectSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<CreateProjectSdkTool> _logger;
public CreateProjectSdkTool(
IPendingChangeService pendingChangeService,
IProjectRepository projectRepository,
ITenantContext tenantContext,
IHttpContextAccessor httpContextAccessor,
DiffPreviewService diffPreviewService,
ILogger<CreateProjectSdkTool> logger)
{
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerTool]
[Description("Create a new project in ColaFlow. Projects organize issues (Epics, Stories, Tasks, Bugs). Requires human approval before being created.")]
public async Task<string> CreateProjectAsync(
[Description("The name of the project (max 100 characters)")] string name,
[Description("The project key (e.g., 'CFD', 'PRJ'). Must be 2-10 uppercase letters and unique within the tenant.")] string key,
[Description("Detailed project description (optional, max 500 characters)")] string? description = null,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing create_project tool (SDK)");
// 1. Validate input
if (string.IsNullOrWhiteSpace(name))
throw new McpInvalidParamsException("Project name cannot be empty");
if (name.Length > 100)
throw new McpInvalidParamsException("Project name cannot exceed 100 characters");
if (string.IsNullOrWhiteSpace(key))
throw new McpInvalidParamsException("Project key cannot be empty");
if (key.Length < 2 || key.Length > 10)
throw new McpInvalidParamsException("Project key must be between 2 and 10 characters");
if (!System.Text.RegularExpressions.Regex.IsMatch(key, "^[A-Z]+$"))
throw new McpInvalidParamsException("Project key must contain only uppercase letters");
if (description?.Length > 500)
throw new McpInvalidParamsException("Project description cannot exceed 500 characters");
// 2. Check if project key already exists
var existingProject = await _projectRepository.GetByKeyAsync(key, cancellationToken);
if (existingProject != null)
throw new McpInvalidParamsException($"Project with key '{key}' already exists");
// 3. Get Owner ID from HTTP context claims
var ownerId = GetUserIdFromClaims();
// 4. Get Tenant ID from context
var tenantId = _tenantContext.GetCurrentTenantId();
// 5. Build "after data" object for diff preview
var afterData = new
{
name = name,
key = key,
description = description ?? string.Empty,
ownerId = ownerId,
tenantId = tenantId,
status = "Active"
};
// 6. Generate Diff Preview (CREATE operation)
var diff = _diffPreviewService.GenerateCreateDiff(
entityType: "Project",
afterEntity: afterData,
entityKey: key // Use project key as the entity key
);
// 7. Create PendingChange (do NOT execute yet)
var pendingChange = await _pendingChangeService.CreateAsync(
new CreatePendingChangeRequest
{
ToolName = "create_project",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - CREATE Project: {Name} ({Key})",
pendingChange.Id, name, key);
// 8. Return pendingChangeId to AI (NOT the created project)
return $"Project creation request submitted for approval.\n\n" +
$"**Pending Change ID**: {pendingChange.Id}\n" +
$"**Status**: Pending Approval\n" +
$"**Project Name**: {name}\n" +
$"**Project Key**: {key}\n" +
$"**Description**: {(string.IsNullOrEmpty(description) ? "(none)" : description)}\n\n" +
$"A human user must approve this change before the project is created. " +
$"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing create_project tool (SDK)");
throw new McpInvalidParamsException($"Error creating project: {ex.Message}");
}
}
private Guid GetUserIdFromClaims()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
throw new McpInvalidParamsException("HTTP context not available");
var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? httpContext.User.FindFirst("sub")?.Value;
if (string.IsNullOrEmpty(userIdClaim))
{
// Fallback: Try to get from API key context
var apiKeyId = httpContext.Items["ApiKeyId"] as Guid?;
if (apiKeyId.HasValue)
{
// Use API key ID as owner ID (for MCP API key authentication)
return apiKeyId.Value;
}
throw new McpInvalidParamsException("User ID not found in authentication context");
}
if (!Guid.TryParse(userIdClaim, out var userId))
throw new McpInvalidParamsException("Invalid user ID format in authentication context");
return userId;
}
}