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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user