From 9f774b56b01414a8552c050882aea6ed72c21302 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 23 Nov 2025 15:36:36 +0100 Subject: [PATCH] feat(backend): Add CreateProjectSdkTool for MCP SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../SdkTools/CreateProjectSdkTool.cs | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/SdkTools/CreateProjectSdkTool.cs diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/SdkTools/CreateProjectSdkTool.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/SdkTools/CreateProjectSdkTool.cs new file mode 100644 index 0000000..f3056aa --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/SdkTools/CreateProjectSdkTool.cs @@ -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; + +/// +/// MCP Tool: create_project (SDK-based implementation) +/// Creates a new Project in ColaFlow +/// Generates a Diff Preview and creates a PendingChange for approval +/// +[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 _logger; + + public CreateProjectSdkTool( + IPendingChangeService pendingChangeService, + IProjectRepository projectRepository, + ITenantContext tenantContext, + IHttpContextAccessor httpContextAccessor, + DiffPreviewService diffPreviewService, + ILogger 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 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; + } +}