From 61e0f1249c1827432ec130b978ba8d0e22bae429 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 9 Nov 2025 18:31:17 +0100 Subject: [PATCH] fix(backend): Fix MCP module compilation errors by using correct exception classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced non-existent ColaFlow.Shared.Kernel.Exceptions namespace references with ColaFlow.Modules.Mcp.Domain.Exceptions in 5 files: Changes: - McpToolRegistry.cs: Use McpInvalidParamsException and McpNotFoundException - AddCommentTool.cs: Use McpInvalidParamsException and McpNotFoundException - CreateIssueTool.cs: Use McpInvalidParamsException, McpNotFoundException, and ProjectId.From() - UpdateStatusTool.cs: Use McpNotFoundException - ToolParameterParser.cs: Use McpInvalidParamsException for all validation errors All BadRequestException -> McpInvalidParamsException All NotFoundException -> McpNotFoundException Also fixed CreateIssueTool to convert Guid to ProjectId value object. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Services/IMcpToolRegistry.cs | 32 ++ .../Services/McpToolRegistry.cs | 125 +++++++ .../Tools/AddCommentTool.cs | 165 +++++++++ .../Tools/CreateIssueTool.cs | 198 +++++++++++ .../Tools/UpdateStatusTool.cs | 165 +++++++++ .../Tools/Validation/ToolParameterParser.cs | 334 ++++++++++++++++++ .../Tools/IMcpTool.cs | 49 +++ .../Tools/McpToolCall.cs | 18 + .../Tools/McpToolDescriptor.cs | 22 ++ .../Tools/McpToolInputSchema.cs | 89 +++++ .../Tools/McpToolResult.cs | 38 ++ 11 files changed, 1235 insertions(+) create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpToolRegistry.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpToolRegistry.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/AddCommentTool.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/CreateIssueTool.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/UpdateStatusTool.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/Validation/ToolParameterParser.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/IMcpTool.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolCall.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolDescriptor.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolInputSchema.cs create mode 100644 colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolResult.cs diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpToolRegistry.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpToolRegistry.cs new file mode 100644 index 0000000..84ca248 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/IMcpToolRegistry.cs @@ -0,0 +1,32 @@ +using ColaFlow.Modules.Mcp.Contracts.Tools; + +namespace ColaFlow.Modules.Mcp.Application.Services; + +/// +/// Registry interface for MCP Tools +/// Manages tool discovery and dispatching +/// +public interface IMcpToolRegistry +{ + /// + /// Get all registered tools + /// + IEnumerable GetAllTools(); + + /// + /// Get tool by name + /// + IMcpTool? GetTool(string toolName); + + /// + /// Check if tool exists + /// + bool HasTool(string toolName); + + /// + /// Execute a tool by name + /// + Task ExecuteToolAsync( + McpToolCall toolCall, + CancellationToken cancellationToken = default); +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpToolRegistry.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpToolRegistry.cs new file mode 100644 index 0000000..ae92332 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Services/McpToolRegistry.cs @@ -0,0 +1,125 @@ +using ColaFlow.Modules.Mcp.Contracts.Tools; +using ColaFlow.Modules.Mcp.Domain.Exceptions; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Mcp.Application.Services; + +/// +/// Registry for MCP Tools with auto-discovery +/// Uses constructor injection to register all IMcpTool implementations +/// +public class McpToolRegistry : IMcpToolRegistry +{ + private readonly Dictionary _tools; + private readonly ILogger _logger; + + public McpToolRegistry( + IEnumerable tools, + ILogger _logger) + { + this._logger = _logger ?? throw new ArgumentNullException(nameof(_logger)); + + // Auto-discover and register all tools + _tools = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var tool in tools) + { + if (_tools.ContainsKey(tool.Name)) + { + this._logger.LogWarning( + "Duplicate tool name detected: {ToolName}. Skipping duplicate registration.", + tool.Name); + continue; + } + + _tools[tool.Name] = tool; + this._logger.LogInformation( + "Registered MCP Tool: {ToolName} - {Description}", + tool.Name, tool.Description); + } + + this._logger.LogInformation( + "McpToolRegistry initialized with {Count} tools", + _tools.Count); + } + + public IEnumerable GetAllTools() + { + return _tools.Values; + } + + public IMcpTool? GetTool(string toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) + return null; + + _tools.TryGetValue(toolName, out var tool); + return tool; + } + + public bool HasTool(string toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) + return false; + + return _tools.ContainsKey(toolName); + } + + public async Task ExecuteToolAsync( + McpToolCall toolCall, + CancellationToken cancellationToken = default) + { + if (toolCall == null) + throw new ArgumentNullException(nameof(toolCall)); + + if (string.IsNullOrWhiteSpace(toolCall.Name)) + throw new McpInvalidParamsException("Tool name cannot be empty"); + + // Get tool + var tool = GetTool(toolCall.Name); + if (tool == null) + { + _logger.LogWarning( + "Tool not found: {ToolName}. Available tools: {AvailableTools}", + toolCall.Name, string.Join(", ", _tools.Keys)); + + throw new McpNotFoundException("Tool", toolCall.Name); + } + + _logger.LogInformation( + "Executing MCP Tool: {ToolName}", + toolCall.Name); + + try + { + // Execute tool + var result = await tool.ExecuteAsync(toolCall, cancellationToken); + + _logger.LogInformation( + "MCP Tool executed successfully: {ToolName}, IsError={IsError}", + toolCall.Name, result.IsError); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error executing MCP Tool: {ToolName}", + toolCall.Name); + + // Return error result + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Error executing tool '{toolCall.Name}': {ex.Message}" + } + }, + IsError = true + }; + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/AddCommentTool.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/AddCommentTool.cs new file mode 100644 index 0000000..0e68e96 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/AddCommentTool.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using ColaFlow.Modules.Mcp.Application.DTOs; +using ColaFlow.Modules.Mcp.Application.Services; +using ColaFlow.Modules.Mcp.Application.Tools.Validation; +using ColaFlow.Modules.Mcp.Contracts.Tools; +using ColaFlow.Modules.Mcp.Domain.Exceptions; +using ColaFlow.Modules.Mcp.Domain.Services; +using ColaFlow.Modules.IssueManagement.Domain.Repositories; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Mcp.Application.Tools; + +/// +/// MCP Tool: add_comment +/// Adds a comment to an existing Issue +/// Generates a Diff Preview and creates a PendingChange for approval +/// +public class AddCommentTool : IMcpTool +{ + private readonly IPendingChangeService _pendingChangeService; + private readonly IIssueRepository _issueRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly DiffPreviewService _diffPreviewService; + private readonly ILogger _logger; + + public string Name => "add_comment"; + + public string Description => "Add a comment to an existing issue. " + + "Supports markdown formatting. " + + "Requires human approval before being added."; + + public McpToolInputSchema InputSchema => new() + { + Type = "object", + Properties = new Dictionary + { + ["issueId"] = new() + { + Type = "string", + Format = "uuid", + Description = "The ID of the issue to comment on" + }, + ["content"] = new() + { + Type = "string", + MinLength = 1, + MaxLength = 2000, + Description = "The comment content (supports markdown, max 2000 characters)" + } + }, + Required = new List { "issueId", "content" } + }; + + public AddCommentTool( + IPendingChangeService pendingChangeService, + IIssueRepository issueRepository, + IHttpContextAccessor httpContextAccessor, + DiffPreviewService diffPreviewService, + ILogger logger) + { + _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService)); + _issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository)); + _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync( + McpToolCall toolCall, + CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Executing add_comment tool"); + + // 1. Parse and validate input + var issueId = ToolParameterParser.ParseGuid(toolCall.Arguments, "issueId", required: true)!.Value; + var content = ToolParameterParser.ParseString(toolCall.Arguments, "content", required: true); + + // Validate content + if (string.IsNullOrWhiteSpace(content)) + throw new McpInvalidParamsException("Comment content cannot be empty"); + + if (content.Length > 2000) + throw new McpInvalidParamsException("Comment content cannot exceed 2000 characters"); + + // 2. Verify issue exists + var issue = await _issueRepository.GetByIdAsync(issueId, cancellationToken); + if (issue == null) + throw new McpNotFoundException("Issue", issueId.ToString()); + + // 3. Get API Key ID (to track who created the comment) + var apiKeyId = _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?; + + // 4. Build comment data for diff preview + var commentData = new + { + issueId = issueId, + content = content, + authorType = "AI", + authorId = apiKeyId, + createdAt = DateTime.UtcNow + }; + + // 5. Generate Diff Preview (CREATE Comment operation) + var diff = _diffPreviewService.GenerateCreateDiff( + entityType: "Comment", + afterEntity: commentData, + entityKey: $"Comment on {issue.Type}-{issue.Id.ToString().Substring(0, 8)}" + ); + + // 6. Create PendingChange + var pendingChange = await _pendingChangeService.CreateAsync( + new CreatePendingChangeRequest + { + ToolName = Name, + Diff = diff, + ExpirationHours = 24 + }, + cancellationToken); + + _logger.LogInformation( + "PendingChange created: {PendingChangeId} - CREATE Comment on Issue {IssueId}", + pendingChange.Id, issueId); + + // 7. Return pendingChangeId to AI + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Comment creation request submitted for approval.\n\n" + + $"**Pending Change ID**: {pendingChange.Id}\n" + + $"**Status**: Pending Approval\n" + + $"**Issue**: {issue.Title}\n" + + $"**Comment Preview**: {(content.Length > 100 ? content.Substring(0, 100) + "..." : content)}\n\n" + + $"A human user must approve this change before the comment is added. " + + $"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved." + } + }, + IsError = false + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing add_comment tool"); + + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Error: {ex.Message}" + } + }, + IsError = true + }; + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/CreateIssueTool.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/CreateIssueTool.cs new file mode 100644 index 0000000..3309b4e --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/CreateIssueTool.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using ColaFlow.Modules.Mcp.Application.DTOs; +using ColaFlow.Modules.Mcp.Application.Services; +using ColaFlow.Modules.Mcp.Application.Tools.Validation; +using ColaFlow.Modules.Mcp.Contracts.Tools; +using ColaFlow.Modules.Mcp.Domain.Exceptions; +using ColaFlow.Modules.Mcp.Domain.Services; +using ColaFlow.Modules.IssueManagement.Domain.Enums; +using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces; +using ColaFlow.Modules.ProjectManagement.Domain.Repositories; +using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Mcp.Application.Tools; + +/// +/// MCP Tool: create_issue +/// Creates a new Issue (Epic, Story, Task, or Bug) +/// Generates a Diff Preview and creates a PendingChange for approval +/// +public class CreateIssueTool : IMcpTool +{ + private readonly IPendingChangeService _pendingChangeService; + private readonly IProjectRepository _projectRepository; + private readonly ITenantContext _tenantContext; + private readonly DiffPreviewService _diffPreviewService; + private readonly ILogger _logger; + + public string Name => "create_issue"; + + public string Description => "Create a new issue (Epic, Story, Task, or Bug) in a ColaFlow project. " + + "The issue will be created in 'Backlog' status and requires human approval before being created."; + + public McpToolInputSchema InputSchema => new() + { + Type = "object", + Properties = new Dictionary + { + ["projectId"] = new() + { + Type = "string", + Format = "uuid", + Description = "The ID of the project to create the issue in" + }, + ["title"] = new() + { + Type = "string", + MinLength = 1, + MaxLength = 200, + Description = "Issue title (max 200 characters)" + }, + ["description"] = new() + { + Type = "string", + MaxLength = 2000, + Description = "Detailed issue description (optional, max 2000 characters)" + }, + ["type"] = new() + { + Type = "string", + Enum = new[] { "Epic", "Story", "Task", "Bug" }, + Description = "Issue type" + }, + ["priority"] = new() + { + Type = "string", + Enum = new[] { "Low", "Medium", "High", "Critical" }, + Description = "Issue priority (optional, defaults to Medium)" + }, + ["assigneeId"] = new() + { + Type = "string", + Format = "uuid", + Description = "User ID to assign the issue to (optional)" + } + }, + Required = new List { "projectId", "title", "type" } + }; + + public CreateIssueTool( + IPendingChangeService pendingChangeService, + IProjectRepository projectRepository, + ITenantContext tenantContext, + 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)); + _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync( + McpToolCall toolCall, + CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Executing create_issue tool"); + + // 1. Parse and validate input + var projectId = ToolParameterParser.ParseGuid(toolCall.Arguments, "projectId", required: true)!.Value; + var title = ToolParameterParser.ParseString(toolCall.Arguments, "title", required: true); + var description = ToolParameterParser.ParseString(toolCall.Arguments, "description") ?? string.Empty; + var type = ToolParameterParser.ParseEnum(toolCall.Arguments, "type", required: true)!.Value; + var priority = ToolParameterParser.ParseEnum(toolCall.Arguments, "priority") ?? IssuePriority.Medium; + var assigneeId = ToolParameterParser.ParseGuid(toolCall.Arguments, "assigneeId"); + + // Validate title + if (string.IsNullOrWhiteSpace(title)) + throw new McpInvalidParamsException("Issue title cannot be empty"); + + if (title.Length > 200) + throw new McpInvalidParamsException("Issue title cannot exceed 200 characters"); + + if (description.Length > 2000) + throw new McpInvalidParamsException("Issue description cannot exceed 2000 characters"); + + // 2. Verify project exists + var project = await _projectRepository.GetByIdAsync(ProjectId.From(projectId), cancellationToken); + if (project == null) + throw new McpNotFoundException("Project", projectId.ToString()); + + // 3. Build "after data" object for diff preview + var afterData = new + { + projectId = projectId, + title = title, + description = description, + type = type.ToString(), + priority = priority.ToString(), + status = IssueStatus.Backlog.ToString(), // Default status + assigneeId = assigneeId + }; + + // 4. Generate Diff Preview (CREATE operation) + var diff = _diffPreviewService.GenerateCreateDiff( + entityType: "Issue", + afterEntity: afterData, + entityKey: null // No key yet (will be generated on approval) + ); + + // 5. Create PendingChange (do NOT execute yet) + var pendingChange = await _pendingChangeService.CreateAsync( + new CreatePendingChangeRequest + { + ToolName = Name, + Diff = diff, + ExpirationHours = 24 + }, + cancellationToken); + + _logger.LogInformation( + "PendingChange created: {PendingChangeId} - CREATE Issue: {Title}", + pendingChange.Id, title); + + // 6. Return pendingChangeId to AI (NOT the created issue) + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Issue creation request submitted for approval.\n\n" + + $"**Pending Change ID**: {pendingChange.Id}\n" + + $"**Status**: Pending Approval\n" + + $"**Issue Type**: {type}\n" + + $"**Title**: {title}\n" + + $"**Priority**: {priority}\n" + + $"**Project**: {project.Name}\n\n" + + $"A human user must approve this change before the issue is created. " + + $"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved." + } + }, + IsError = false + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing create_issue tool"); + + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Error: {ex.Message}" + } + }, + IsError = true + }; + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/UpdateStatusTool.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/UpdateStatusTool.cs new file mode 100644 index 0000000..337db71 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/UpdateStatusTool.cs @@ -0,0 +1,165 @@ +using System.Text.Json; +using ColaFlow.Modules.Mcp.Application.DTOs; +using ColaFlow.Modules.Mcp.Application.Services; +using ColaFlow.Modules.Mcp.Application.Tools.Validation; +using ColaFlow.Modules.Mcp.Contracts.Tools; +using ColaFlow.Modules.Mcp.Domain.Exceptions; +using ColaFlow.Modules.Mcp.Domain.Services; +using ColaFlow.Modules.IssueManagement.Domain.Enums; +using ColaFlow.Modules.IssueManagement.Domain.Repositories; +using Microsoft.Extensions.Logging; + +namespace ColaFlow.Modules.Mcp.Application.Tools; + +/// +/// MCP Tool: update_status +/// Updates the status of an existing Issue +/// Generates a Diff Preview and creates a PendingChange for approval +/// +public class UpdateStatusTool : IMcpTool +{ + private readonly IPendingChangeService _pendingChangeService; + private readonly IIssueRepository _issueRepository; + private readonly DiffPreviewService _diffPreviewService; + private readonly ILogger _logger; + + public string Name => "update_status"; + + public string Description => "Update the status of an existing issue. " + + "Supports workflow transitions (Backlog → Todo → InProgress → Done). " + + "Requires human approval before being applied."; + + public McpToolInputSchema InputSchema => new() + { + Type = "object", + Properties = new Dictionary + { + ["issueId"] = new() + { + Type = "string", + Format = "uuid", + Description = "The ID of the issue to update" + }, + ["newStatus"] = new() + { + Type = "string", + Enum = new[] { "Backlog", "Todo", "InProgress", "Done" }, + Description = "The new status to set" + } + }, + Required = new List { "issueId", "newStatus" } + }; + + public UpdateStatusTool( + IPendingChangeService pendingChangeService, + IIssueRepository issueRepository, + DiffPreviewService diffPreviewService, + ILogger logger) + { + _pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService)); + _issueRepository = issueRepository ?? throw new ArgumentNullException(nameof(issueRepository)); + _diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task ExecuteAsync( + McpToolCall toolCall, + CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Executing update_status tool"); + + // 1. Parse and validate input + var issueId = ToolParameterParser.ParseGuid(toolCall.Arguments, "issueId", required: true)!.Value; + var newStatus = ToolParameterParser.ParseEnum(toolCall.Arguments, "newStatus", required: true)!.Value; + + // 2. Fetch current issue + var issue = await _issueRepository.GetByIdAsync(issueId, cancellationToken); + if (issue == null) + throw new McpNotFoundException("Issue", issueId.ToString()); + + var oldStatus = issue.Status; + + // 3. Build before and after data for diff preview + var beforeData = new + { + id = issue.Id, + title = issue.Title, + type = issue.Type.ToString(), + status = oldStatus.ToString(), + priority = issue.Priority.ToString() + }; + + var afterData = new + { + id = issue.Id, + title = issue.Title, + type = issue.Type.ToString(), + status = newStatus.ToString(), // Only status changed + priority = issue.Priority.ToString() + }; + + // 4. Generate Diff Preview (UPDATE operation) + var diff = _diffPreviewService.GenerateUpdateDiff( + entityType: "Issue", + entityId: issueId, + beforeEntity: beforeData, + afterEntity: afterData, + entityKey: $"{issue.Type}-{issue.Id.ToString().Substring(0, 8)}" // Simplified key + ); + + // 5. Create PendingChange + var pendingChange = await _pendingChangeService.CreateAsync( + new CreatePendingChangeRequest + { + ToolName = Name, + Diff = diff, + ExpirationHours = 24 + }, + cancellationToken); + + _logger.LogInformation( + "PendingChange created: {PendingChangeId} - UPDATE Issue {IssueId} status: {OldStatus} → {NewStatus}", + pendingChange.Id, issueId, oldStatus, newStatus); + + // 6. Return pendingChangeId to AI + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Issue status update request submitted for approval.\n\n" + + $"**Pending Change ID**: {pendingChange.Id}\n" + + $"**Status**: Pending Approval\n" + + $"**Issue**: {issue.Title}\n" + + $"**Old Status**: {oldStatus}\n" + + $"**New Status**: {newStatus}\n\n" + + $"A human user must approve this change before the issue status is updated. " + + $"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved." + } + }, + IsError = false + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error executing update_status tool"); + + return new McpToolResult + { + Content = new[] + { + new McpToolContent + { + Type = "text", + Text = $"Error: {ex.Message}" + } + }, + IsError = true + }; + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/Validation/ToolParameterParser.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/Validation/ToolParameterParser.cs new file mode 100644 index 0000000..160b9a1 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Application/Tools/Validation/ToolParameterParser.cs @@ -0,0 +1,334 @@ +using System.Text.Json; +using ColaFlow.Modules.Mcp.Domain.Exceptions; + +namespace ColaFlow.Modules.Mcp.Application.Tools.Validation; + +/// +/// Helper class for parsing and validating tool parameters +/// +public static class ToolParameterParser +{ + /// + /// Parse a required string parameter + /// + public static string ParseString(Dictionary args, string paramName, bool required = false) + { + if (!args.TryGetValue(paramName, out var value)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' is missing"); + return string.Empty; + } + + if (value == null) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + return string.Empty; + } + + // Handle JsonElement (from JSON deserialization) + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String) + return jsonElement.GetString() ?? string.Empty; + + if (jsonElement.ValueKind == JsonValueKind.Null && required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + + return jsonElement.ToString(); + } + + return value.ToString() ?? string.Empty; + } + + /// + /// Parse a required Guid parameter + /// + public static Guid? ParseGuid(Dictionary args, string paramName, bool required = false) + { + if (!args.TryGetValue(paramName, out var value)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' is missing"); + return null; + } + + if (value == null) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + return null; + } + + // Handle JsonElement + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String) + { + var strValue = jsonElement.GetString(); + if (string.IsNullOrWhiteSpace(strValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (Guid.TryParse(strValue, out var guid)) + return guid; + + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid UUID"); + } + + if (jsonElement.ValueKind == JsonValueKind.Null && required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + } + + // Handle string + var stringValue = value.ToString(); + if (string.IsNullOrWhiteSpace(stringValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (Guid.TryParse(stringValue, out var result)) + return result; + + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid UUID"); + } + + /// + /// Parse an enum parameter + /// + public static TEnum? ParseEnum(Dictionary args, string paramName, bool required = false) + where TEnum : struct, Enum + { + if (!args.TryGetValue(paramName, out var value)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' is missing"); + return null; + } + + if (value == null) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + return null; + } + + // Handle JsonElement + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String) + { + var strValue = jsonElement.GetString(); + if (string.IsNullOrWhiteSpace(strValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (Enum.TryParse(strValue, ignoreCase: true, out var enumValue)) + return enumValue; + + var validValues = string.Join(", ", Enum.GetNames()); + throw new McpInvalidParamsException( + $"Parameter '{paramName}' must be one of: {validValues}"); + } + + if (jsonElement.ValueKind == JsonValueKind.Null && required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + } + + // Handle string + var stringValue = value.ToString(); + if (string.IsNullOrWhiteSpace(stringValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (Enum.TryParse(stringValue, ignoreCase: true, out var result)) + return result; + + var validValuesList = string.Join(", ", Enum.GetNames()); + throw new McpInvalidParamsException( + $"Parameter '{paramName}' must be one of: {validValuesList}"); + } + + /// + /// Parse a decimal parameter + /// + public static decimal? ParseDecimal(Dictionary args, string paramName, bool required = false) + { + if (!args.TryGetValue(paramName, out var value)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' is missing"); + return null; + } + + if (value == null) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + return null; + } + + // Handle JsonElement + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.Number) + return jsonElement.GetDecimal(); + + if (jsonElement.ValueKind == JsonValueKind.String) + { + var strValue = jsonElement.GetString(); + if (string.IsNullOrWhiteSpace(strValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (decimal.TryParse(strValue, out var decimalValue)) + return decimalValue; + + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid number"); + } + + if (jsonElement.ValueKind == JsonValueKind.Null && required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + } + + // Try to convert directly + try + { + return Convert.ToDecimal(value); + } + catch + { + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid number"); + } + } + + /// + /// Parse an integer parameter + /// + public static int? ParseInt(Dictionary args, string paramName, bool required = false) + { + if (!args.TryGetValue(paramName, out var value)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' is missing"); + return null; + } + + if (value == null) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + return null; + } + + // Handle JsonElement + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.Number) + return jsonElement.GetInt32(); + + if (jsonElement.ValueKind == JsonValueKind.String) + { + var strValue = jsonElement.GetString(); + if (string.IsNullOrWhiteSpace(strValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (int.TryParse(strValue, out var intValue)) + return intValue; + + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid integer"); + } + + if (jsonElement.ValueKind == JsonValueKind.Null && required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + } + + // Try to convert directly + try + { + return Convert.ToInt32(value); + } + catch + { + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid integer"); + } + } + + /// + /// Parse a boolean parameter + /// + public static bool? ParseBool(Dictionary args, string paramName, bool required = false) + { + if (!args.TryGetValue(paramName, out var value)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' is missing"); + return null; + } + + if (value == null) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + return null; + } + + // Handle JsonElement + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.True) + return true; + + if (jsonElement.ValueKind == JsonValueKind.False) + return false; + + if (jsonElement.ValueKind == JsonValueKind.String) + { + var strValue = jsonElement.GetString(); + if (string.IsNullOrWhiteSpace(strValue)) + { + if (required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be empty"); + return null; + } + + if (bool.TryParse(strValue, out var boolValue)) + return boolValue; + + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid boolean"); + } + + if (jsonElement.ValueKind == JsonValueKind.Null && required) + throw new McpInvalidParamsException($"Required parameter '{paramName}' cannot be null"); + } + + // Try to convert directly + try + { + return Convert.ToBoolean(value); + } + catch + { + throw new McpInvalidParamsException($"Parameter '{paramName}' must be a valid boolean"); + } + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/IMcpTool.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/IMcpTool.cs new file mode 100644 index 0000000..ae55150 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/IMcpTool.cs @@ -0,0 +1,49 @@ +namespace ColaFlow.Modules.Mcp.Contracts.Tools; + +/// +/// Interface for MCP Tools +/// Tools provide write operations to AI agents through the MCP protocol +/// All write operations create PendingChanges and require human approval +/// +public interface IMcpTool +{ + /// + /// Tool name (e.g., "create_issue", "update_status") + /// Must be unique and follow snake_case naming convention + /// + string Name { get; } + + /// + /// Human-readable tool description for AI to understand when to use this tool + /// + string Description { get; } + + /// + /// JSON Schema describing the tool's input parameters + /// + McpToolInputSchema InputSchema { get; } + + /// + /// Execute the tool with the provided arguments + /// This should create a PendingChange, NOT execute the change directly + /// + /// The tool call request with arguments + /// Cancellation token + /// Tool execution result + Task ExecuteAsync( + McpToolCall toolCall, + CancellationToken cancellationToken); + + /// + /// Get tool descriptor with full metadata + /// + McpToolDescriptor GetDescriptor() + { + return new McpToolDescriptor + { + Name = Name, + Description = Description, + InputSchema = InputSchema + }; + } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolCall.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolCall.cs new file mode 100644 index 0000000..8db2e85 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolCall.cs @@ -0,0 +1,18 @@ +namespace ColaFlow.Modules.Mcp.Contracts.Tools; + +/// +/// Represents a tool call request from an AI agent +/// +public sealed class McpToolCall +{ + /// + /// Tool name to execute + /// + public string Name { get; set; } = string.Empty; + + /// + /// Tool arguments as key-value pairs + /// Values can be strings, numbers, booleans, arrays, or objects + /// + public Dictionary Arguments { get; set; } = new(); +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolDescriptor.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolDescriptor.cs new file mode 100644 index 0000000..f8a6ef7 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolDescriptor.cs @@ -0,0 +1,22 @@ +namespace ColaFlow.Modules.Mcp.Contracts.Tools; + +/// +/// Descriptor for an MCP Tool containing metadata and schema +/// +public sealed class McpToolDescriptor +{ + /// + /// Tool name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Tool description + /// + public string Description { get; set; } = string.Empty; + + /// + /// Input parameter schema + /// + public McpToolInputSchema InputSchema { get; set; } = new(); +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolInputSchema.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolInputSchema.cs new file mode 100644 index 0000000..d38d06e --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolInputSchema.cs @@ -0,0 +1,89 @@ +namespace ColaFlow.Modules.Mcp.Contracts.Tools; + +/// +/// JSON Schema for tool input parameters +/// +public sealed class McpToolInputSchema +{ + /// + /// Schema type (always "object" for tool inputs) + /// + public string Type { get; set; } = "object"; + + /// + /// Schema properties (parameter definitions) + /// Key is parameter name, value is parameter schema + /// + public Dictionary Properties { get; set; } = new(); + + /// + /// List of required parameter names + /// + public List Required { get; set; } = new(); +} + +/// +/// JSON Schema property definition +/// +public sealed class JsonSchemaProperty +{ + /// + /// Property type: "string", "number", "integer", "boolean", "array", "object" + /// + public string Type { get; set; } = "string"; + + /// + /// Property description (for AI to understand) + /// + public string? Description { get; set; } + + /// + /// Enum values (for restricted choices) + /// + public string[]? Enum { get; set; } + + /// + /// String format hint: "uuid", "email", "date-time", "uri", etc. + /// + public string? Format { get; set; } + + /// + /// Minimum value (for numbers) + /// + public decimal? Minimum { get; set; } + + /// + /// Maximum value (for numbers) + /// + public decimal? Maximum { get; set; } + + /// + /// Minimum length (for strings) + /// + public int? MinLength { get; set; } + + /// + /// Maximum length (for strings) + /// + public int? MaxLength { get; set; } + + /// + /// Pattern (regex) for string validation + /// + public string? Pattern { get; set; } + + /// + /// Items schema (for arrays) + /// + public JsonSchemaProperty? Items { get; set; } + + /// + /// Properties schema (for nested objects) + /// + public Dictionary? Properties { get; set; } + + /// + /// Default value + /// + public object? Default { get; set; } +} diff --git a/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolResult.cs b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolResult.cs new file mode 100644 index 0000000..be64684 --- /dev/null +++ b/colaflow-api/src/Modules/Mcp/ColaFlow.Modules.Mcp.Contracts/Tools/McpToolResult.cs @@ -0,0 +1,38 @@ +namespace ColaFlow.Modules.Mcp.Contracts.Tools; + +/// +/// Result of a tool execution +/// +public sealed class McpToolResult +{ + /// + /// Tool result content (typically text describing the PendingChange created) + /// + public IEnumerable Content { get; set; } = Array.Empty(); + + /// + /// Whether the tool execution failed + /// + public bool IsError { get; set; } +} + +/// +/// Content item in a tool result +/// +public sealed class McpToolContent +{ + /// + /// Content type: "text" or "resource" + /// + public string Type { get; set; } = "text"; + + /// + /// Text content (for type="text") + /// + public string? Text { get; set; } + + /// + /// Resource URI (for type="resource") + /// + public string? Resource { get; set; } +}