Clean up
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled

This commit is contained in:
Yaojia Wang
2025-11-15 08:58:48 +01:00
parent 4479c9ef91
commit 34a379750f
32 changed files with 7537 additions and 24 deletions

View File

@@ -0,0 +1,105 @@
// C# Script to explore ModelContextProtocol SDK APIs
#r "nuget: ModelContextProtocol, 0.4.0-preview.3"
using System;
using System.Reflection;
using System.Linq;
// Load the ModelContextProtocol assembly
var mcpAssembly = Assembly.Load("ModelContextProtocol");
Console.WriteLine("=== ModelContextProtocol SDK API Exploration ===");
Console.WriteLine($"Assembly: {mcpAssembly.FullName}");
Console.WriteLine();
// Get all public types
var types = mcpAssembly.GetExportedTypes()
.OrderBy(t => t.Namespace)
.ThenBy(t => t.Name);
Console.WriteLine($"Total Public Types: {types.Count()}");
Console.WriteLine();
// Group by namespace
var namespaces = types.GroupBy(t => t.Namespace ?? "No Namespace");
foreach (var ns in namespaces)
{
Console.WriteLine($"\n### Namespace: {ns.Key}");
Console.WriteLine(new string('-', 60));
foreach (var type in ns)
{
var typeKind = type.IsInterface ? "interface" :
type.IsClass && type.IsAbstract ? "abstract class" :
type.IsClass ? "class" :
type.IsEnum ? "enum" :
type.IsValueType ? "struct" : "type";
Console.WriteLine($" [{typeKind}] {type.Name}");
// Show attributes
var attrs = type.GetCustomAttributes(false);
if (attrs.Any())
{
foreach (var attr in attrs)
{
Console.WriteLine($" @{attr.GetType().Name}");
}
}
}
}
// Look for specific patterns
Console.WriteLine("\n\n=== Looking for MCP-Specific Patterns ===");
Console.WriteLine(new string('-', 60));
// Look for Tool-related types
var toolTypes = types.Where(t => t.Name.Contains("Tool", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nTool-related types ({toolTypes.Count()}):");
foreach (var t in toolTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Resource-related types
var resourceTypes = types.Where(t => t.Name.Contains("Resource", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nResource-related types ({resourceTypes.Count()}):");
foreach (var t in resourceTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Attribute types
var attributeTypes = types.Where(t => t.Name.EndsWith("Attribute", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nAttribute types ({attributeTypes.Count()}):");
foreach (var t in attributeTypes)
{
Console.WriteLine($" - {t.Name}");
}
// Look for Server-related types
var serverTypes = types.Where(t => t.Name.Contains("Server", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nServer-related types ({serverTypes.Count()}):");
foreach (var t in serverTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Client-related types
var clientTypes = types.Where(t => t.Name.Contains("Client", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nClient-related types ({clientTypes.Count()}):");
foreach (var t in clientTypes)
{
Console.WriteLine($" - {t.FullName}");
}
// Look for Transport-related types
var transportTypes = types.Where(t => t.Name.Contains("Transport", StringComparison.OrdinalIgnoreCase));
Console.WriteLine($"\nTransport-related types ({transportTypes.Count()}):");
foreach (var t in transportTypes)
{
Console.WriteLine($" - {t.FullName}");
}
Console.WriteLine("\n=== Exploration Complete ===");

View File

@@ -14,6 +14,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.4.0-preview.3" />
<PackageReference Include="Scalar.AspNetCore" Version="2.9.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>

View File

@@ -0,0 +1,26 @@
// Temporary file to explore ModelContextProtocol SDK APIs
// This file will be deleted after exploration
using ModelContextProtocol;
using Microsoft.Extensions.DependencyInjection;
namespace ColaFlow.API.Exploration;
/// <summary>
/// Temporary class to explore ModelContextProtocol SDK APIs
/// </summary>
public class McpSdkExplorer
{
public void ExploreServices(IServiceCollection services)
{
// Try to discover SDK extension methods
// services.AddMcp...
// services.AddModelContext...
}
public void ExploreTypes()
{
// List all types we can discover
// var type = typeof(???);
}
}

View File

@@ -45,15 +45,17 @@ builder.Services.AddIssueManagementModule(builder.Configuration, builder.Environ
builder.Services.AddIdentityApplication();
builder.Services.AddIdentityInfrastructure(builder.Configuration, builder.Environment);
// Register MCP Module (Custom Implementation)
// Register MCP Module (Custom Implementation - Keep for Diff Preview services)
builder.Services.AddMcpModule(builder.Configuration);
// ============================================
// Register Microsoft MCP SDK (PoC - Phase 1)
// Register Microsoft MCP SDK (Official)
// ============================================
builder.Services.AddMcpServer()
.WithToolsFromAssembly() // Auto-discover tools with [McpServerToolType] attribute
.WithResourcesFromAssembly(); // Auto-discover resources with [McpServerResourceType] attribute
.WithHttpTransport() // Required for MapMcp() endpoint
.WithToolsFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.SdkTools.CreateIssueSdkTool).Assembly)
.WithResourcesFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.SdkResources.ProjectsSdkResource).Assembly)
.WithPromptsFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.SdkPrompts.ProjectManagementPrompts).Assembly);
// Add Response Caching
builder.Services.AddResponseCaching();
@@ -235,6 +237,12 @@ app.MapHub<ProjectHub>("/hubs/project");
app.MapHub<NotificationHub>("/hubs/notification");
app.MapHub<McpNotificationHub>("/hubs/mcp-notifications");
// ============================================
// Map MCP SDK Endpoint
// ============================================
app.MapMcp("/mcp-sdk"); // Official SDK endpoint at /mcp-sdk
// Note: Legacy /mcp endpoint still handled by UseMcpMiddleware() above
// ============================================
// Auto-migrate databases in development
// ============================================

View File

@@ -18,7 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,237 @@
using System.ComponentModel;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkPrompts;
/// <summary>
/// MCP Prompts for project management tasks
/// Provides pre-defined prompt templates for AI interactions
/// </summary>
[McpServerPromptType]
public static class ProjectManagementPrompts
{
[McpServerPrompt]
[Description("Generate a Product Requirements Document (PRD) for an Epic")]
public static ChatMessage GeneratePrdPrompt(
[Description("The Epic title")] string epicTitle,
[Description("Brief description of the Epic")] string epicDescription)
{
var promptText = $@"You are a Product Manager creating a Product Requirements Document (PRD).
**Epic**: {epicTitle}
**Description**: {epicDescription}
Please create a comprehensive PRD that includes:
1. **Executive Summary**
- Brief overview of the feature
- Business value and goals
2. **User Stories**
- Who are the users?
- What problems does this solve?
3. **Functional Requirements**
- List all key features
- User workflows and interactions
4. **Non-Functional Requirements**
- Performance expectations
- Security considerations
- Scalability needs
5. **Acceptance Criteria**
- Clear, testable criteria for completion
- Success metrics
6. **Technical Considerations**
- API requirements
- Data models
- Integration points
7. **Timeline and Milestones**
- Estimated timeline
- Key milestones
- Dependencies
Please format the PRD in Markdown.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Break down an Epic into smaller Stories")]
public static ChatMessage SplitEpicToStoriesPrompt(
[Description("The Epic title")] string epicTitle,
[Description("The Epic description or PRD")] string epicContent)
{
var promptText = $@"You are a Product Manager breaking down an Epic into manageable Stories.
**Epic**: {epicTitle}
**Epic Content**:
{epicContent}
Please break this Epic down into 5-10 User Stories following these guidelines:
1. **Each Story should**:
- Be independently valuable
- Be completable in 1-3 days
- Follow the format: ""As a [user], I want [feature] so that [benefit]""
- Include acceptance criteria
2. **Story Structure**:
```
**Story Title**: [Concise title]
**User Story**: As a [user], I want [feature] so that [benefit]
**Description**: [Detailed description]
**Acceptance Criteria**:
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
**Estimated Effort**: [Small/Medium/Large]
**Priority**: [High/Medium/Low]
```
3. **Prioritize the Stories**:
- Mark dependencies between stories
- Suggest implementation order
Please output the Stories in Markdown format.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Generate acceptance criteria for a Story")]
public static ChatMessage GenerateAcceptanceCriteriaPrompt(
[Description("The Story title")] string storyTitle,
[Description("The Story description")] string storyDescription)
{
var promptText = $@"You are a QA Engineer defining acceptance criteria for a User Story.
**Story**: {storyTitle}
**Description**: {storyDescription}
Please create comprehensive acceptance criteria following these guidelines:
1. **Criteria should be**:
- Specific and measurable
- Testable (can be verified)
- Clear and unambiguous
- Focused on outcomes, not implementation
2. **Include**:
- Functional acceptance criteria (what the feature does)
- Non-functional acceptance criteria (performance, security, UX)
- Edge cases and error scenarios
3. **Format**:
```
**Given**: [Initial context/state]
**When**: [Action taken]
**Then**: [Expected outcome]
```
Please output 5-10 acceptance criteria in Markdown format.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Analyze Sprint progress and provide insights")]
public static ChatMessage AnalyzeSprintProgressPrompt(
[Description("Sprint name")] string sprintName,
[Description("Sprint data (JSON format)")] string sprintData)
{
var promptText = $@"You are a Scrum Master analyzing Sprint progress.
**Sprint**: {sprintName}
**Sprint Data**:
```json
{sprintData}
```
Please analyze the Sprint and provide:
1. **Progress Summary**:
- Overall completion percentage
- Story points completed vs. planned
- Burndown trend analysis
2. **Risk Assessment**:
- Tasks at risk of not completing
- Blockers and bottlenecks
- Velocity concerns
3. **Recommendations**:
- Actions to get back on track
- Tasks that could be descoped
- Resource allocation suggestions
4. **Team Health**:
- Workload distribution
- Identify overloaded team members
- Suggest load balancing
Please format the analysis in Markdown with clear sections.";
return new ChatMessage(ChatRole.User, promptText);
}
[McpServerPrompt]
[Description("Generate a Sprint retrospective summary")]
public static ChatMessage GenerateRetrospectivePrompt(
[Description("Sprint name")] string sprintName,
[Description("Sprint completion data")] string sprintData,
[Description("Team feedback (optional)")] string? teamFeedback = null)
{
var feedbackSection = string.IsNullOrEmpty(teamFeedback)
? ""
: $@"
**Team Feedback**:
{teamFeedback}";
var promptText = $@"You are a Scrum Master facilitating a Sprint Retrospective.
**Sprint**: {sprintName}
**Sprint Data**:
```json
{sprintData}
```
{feedbackSection}
Please create a comprehensive retrospective summary using the format:
1. **What Went Well** 🎉
- Successes and achievements
- Team highlights
2. **What Didn't Go Well** 😞
- Challenges faced
- Missed goals
- Technical issues
3. **Lessons Learned** 📚
- Key takeaways
- Insights gained
4. **Action Items** 🎯
- Specific, actionable improvements
- Owner for each action
- Target date
5. **Metrics** 📊
- Velocity achieved
- Story points completed
- Sprint goal achievement
Please format the retrospective in Markdown.";
return new ChatMessage(ChatRole.User, promptText);
}
}

View File

@@ -0,0 +1,206 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Issues (SDK-based implementation)
/// Provides search and get functionality for Issues (Epics, Stories, Tasks)
/// </summary>
[McpServerResourceType]
public class IssuesSdkResource
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<IssuesSdkResource> _logger;
public IssuesSdkResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<IssuesSdkResource> logger)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("Search issues with filters (status, priority, assignee, type)")]
public async Task<string> SearchIssuesAsync(
[Description("Filter by project ID (optional)")] Guid? project = null,
[Description("Filter by status (optional)")] string? status = null,
[Description("Filter by priority (optional)")] string? priority = null,
[Description("Filter by type: epic, story, or task (optional)")] string? type = null,
[Description("Filter by assignee ID (optional)")] Guid? assignee = null,
[Description("Maximum number of results (default: 100)")] int limit = 100,
[Description("Offset for pagination (default: 0)")] int offset = 0,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Searching issues for tenant {TenantId} (SDK)", tenantId);
// Limit max results
limit = Math.Min(limit, 100);
// Get projects
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
// Filter by project if specified
if (project.HasValue)
{
var projectId = ProjectId.From(project.Value);
var singleProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
projects = singleProject != null ? new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project> { singleProject } : new();
}
else
{
// Load full hierarchy for all projects
var projectsWithHierarchy = new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project>();
foreach (var p in projects)
{
var fullProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
if (fullProject != null)
{
projectsWithHierarchy.Add(fullProject);
}
}
projects = projectsWithHierarchy;
}
// Collect all issues
var allIssues = new List<object>();
foreach (var proj in projects)
{
if (proj.Epics == null) continue;
foreach (var epic in proj.Epics)
{
// Filter Epics
if (ShouldIncludeIssue("epic", type, epic.Status.ToString(), status,
epic.Priority.ToString(), priority, null, assignee?.ToString()))
{
allIssues.Add(new
{
id = epic.Id.Value,
type = "Epic",
name = epic.Name,
description = epic.Description,
status = epic.Status.ToString(),
priority = epic.Priority.ToString(),
projectId = proj.Id.Value,
projectName = proj.Name,
createdAt = epic.CreatedAt,
storyCount = epic.Stories?.Count ?? 0
});
}
// Filter Stories
if (epic.Stories != null)
{
foreach (var story in epic.Stories)
{
if (ShouldIncludeIssue("story", type, story.Status.ToString(), status,
story.Priority.ToString(), priority, story.AssigneeId?.Value.ToString(), assignee?.ToString()))
{
allIssues.Add(new
{
id = story.Id.Value,
type = "Story",
title = story.Title,
description = story.Description,
status = story.Status.ToString(),
priority = story.Priority.ToString(),
assigneeId = story.AssigneeId?.Value,
projectId = proj.Id.Value,
projectName = proj.Name,
epicId = epic.Id.Value,
epicName = epic.Name,
createdAt = story.CreatedAt,
taskCount = story.Tasks?.Count ?? 0
});
}
// Filter Tasks
if (story.Tasks != null)
{
foreach (var task in story.Tasks)
{
if (ShouldIncludeIssue("task", type, task.Status.ToString(), status,
task.Priority.ToString(), priority, task.AssigneeId?.Value.ToString(), assignee?.ToString()))
{
allIssues.Add(new
{
id = task.Id.Value,
type = "Task",
title = task.Title,
description = task.Description,
status = task.Status.ToString(),
priority = task.Priority.ToString(),
assigneeId = task.AssigneeId?.Value,
projectId = proj.Id.Value,
projectName = proj.Name,
storyId = story.Id.Value,
storyTitle = story.Title,
epicId = epic.Id.Value,
epicName = epic.Name,
createdAt = task.CreatedAt
});
}
}
}
}
}
}
}
// Apply pagination
var total = allIssues.Count;
var paginatedIssues = allIssues.Skip(offset).Take(limit).ToList();
var result = JsonSerializer.Serialize(new
{
issues = paginatedIssues,
total = total,
limit = limit,
offset = offset
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Found {Count} issues for tenant {TenantId} (SDK, total: {Total})",
paginatedIssues.Count, tenantId, total);
return result;
}
private bool ShouldIncludeIssue(
string issueType,
string? typeFilter,
string status,
string? statusFilter,
string priority,
string? priorityFilter,
string? assigneeId,
string? assigneeFilter)
{
if (!string.IsNullOrEmpty(typeFilter) && !issueType.Equals(typeFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(statusFilter) && !status.Equals(statusFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(priorityFilter) && !priority.Equals(priorityFilter, StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrEmpty(assigneeFilter) && assigneeId != assigneeFilter)
return false;
return true;
}
}

View File

@@ -0,0 +1,106 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Projects (SDK-based implementation)
/// Provides access to project data in the current tenant
/// </summary>
[McpServerResourceType]
public class ProjectsSdkResource
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ProjectsSdkResource> _logger;
public ProjectsSdkResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<ProjectsSdkResource> logger)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("List all projects in current tenant")]
public async Task<string> ListProjectsAsync(CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching projects list for tenant {TenantId} (SDK)", tenantId);
// Get all projects (read-only)
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
// Map to DTOs
var projectDtos = projects.Select(p => new
{
id = p.Id.Value,
name = p.Name,
key = p.Key.ToString(),
description = p.Description,
status = p.Status.ToString(),
createdAt = p.CreatedAt,
updatedAt = p.UpdatedAt,
epicCount = p.Epics?.Count ?? 0
}).ToList();
var result = JsonSerializer.Serialize(new
{
projects = projectDtos,
total = projectDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} projects for tenant {TenantId} (SDK)", projectDtos.Count, tenantId);
return result;
}
[McpServerResource]
[Description("Get detailed information about a specific project")]
public async Task<string> GetProjectAsync(
[Description("The project ID")] Guid projectId,
CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId} (SDK)", projectId, tenantId);
var project = await _projectRepository.GetByIdAsync(
ProjectManagement.Domain.ValueObjects.ProjectId.From(projectId),
cancellationToken);
if (project == null)
{
throw new InvalidOperationException($"Project with ID {projectId} not found");
}
var result = JsonSerializer.Serialize(new
{
id = project.Id.Value,
name = project.Name,
key = project.Key.ToString(),
description = project.Description,
status = project.Status.ToString(),
createdAt = project.CreatedAt,
updatedAt = project.UpdatedAt,
epics = project.Epics?.Select(e => new
{
id = e.Id.Value,
title = e.Name, // Epic uses Name instead of Title
status = e.Status.ToString()
}).ToList() ?? (object)new List<object>()
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId} (SDK)", projectId, tenantId);
return result;
}
}

View File

@@ -0,0 +1,76 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.Mcp.Domain.Exceptions;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Sprints (SDK-based implementation)
/// Provides access to Sprint data
/// </summary>
[McpServerResourceType]
public class SprintsSdkResource
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SprintsSdkResource> _logger;
public SprintsSdkResource(
IProjectRepository projectRepository,
ITenantContext tenantContext,
ILogger<SprintsSdkResource> logger)
{
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("Get the currently active Sprint(s)")]
public async Task<string> GetCurrentSprintAsync(CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching active sprints for tenant {TenantId} (SDK)", tenantId);
// Get active sprints
var activeSprints = await _projectRepository.GetActiveSprintsAsync(cancellationToken);
if (activeSprints.Count == 0)
{
_logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
throw new McpNotFoundException("No active sprints found");
}
// Map to DTOs with statistics
var sprintDtos = activeSprints.Select(sprint => new
{
id = sprint.Id.Value,
name = sprint.Name,
goal = sprint.Goal,
status = sprint.Status.ToString(),
startDate = sprint.StartDate,
endDate = sprint.EndDate,
createdAt = sprint.CreatedAt,
statistics = new
{
totalTasks = sprint.TaskIds?.Count ?? 0
}
}).ToList();
var result = JsonSerializer.Serialize(new
{
sprints = sprintDtos,
total = sprintDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId} (SDK)",
sprintDtos.Count, tenantId);
return result;
}
}

View File

@@ -0,0 +1,65 @@
using System.ComponentModel;
using System.Text.Json;
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
using ColaFlow.Modules.Identity.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkResources;
/// <summary>
/// MCP Resource: Users (SDK-based implementation)
/// Provides access to team member data
/// </summary>
[McpServerResourceType]
public class UsersSdkResource
{
private readonly IUserRepository _userRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<UsersSdkResource> _logger;
public UsersSdkResource(
IUserRepository userRepository,
ITenantContext tenantContext,
ILogger<UsersSdkResource> logger)
{
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
[McpServerResource]
[Description("List all team members in current tenant")]
public async Task<string> ListUsersAsync(CancellationToken cancellationToken = default)
{
var tenantId = _tenantContext.GetCurrentTenantId();
_logger.LogDebug("Fetching users list for tenant {TenantId} (SDK)", tenantId);
// Get all users for tenant
var users = await _userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
// Map to DTOs
var userDtos = users.Select(u => new
{
id = u.Id,
email = u.Email.Value,
fullName = u.FullName.ToString(),
status = u.Status.ToString(),
createdAt = u.CreatedAt,
avatarUrl = u.AvatarUrl,
jobTitle = u.JobTitle
}).ToList();
var result = JsonSerializer.Serialize(new
{
users = userDtos,
total = userDtos.Count
}, new JsonSerializerOptions { WriteIndented = true });
_logger.LogInformation("Retrieved {Count} users for tenant {TenantId} (SDK)", userDtos.Count, tenantId);
return result;
}
}

View File

@@ -0,0 +1,117 @@
using System.ComponentModel;
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.IssueManagement.Domain.Repositories;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: add_comment (SDK-based implementation)
/// Adds a comment to an existing Issue
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class AddCommentSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IIssueRepository _issueRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<AddCommentSdkTool> _logger;
public AddCommentSdkTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
IHttpContextAccessor httpContextAccessor,
DiffPreviewService diffPreviewService,
ILogger<AddCommentSdkTool> 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));
}
[McpServerTool]
[Description("Add a comment to an existing issue. Supports markdown formatting. Requires human approval before being added.")]
public async Task<string> AddCommentAsync(
[Description("The ID of the issue to comment on")] Guid issueId,
[Description("The comment content (supports markdown, max 2000 characters)")] string content,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing add_comment tool (SDK)");
// 1. 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 = "add_comment",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - CREATE Comment on Issue {IssueId}",
pendingChange.Id, issueId);
// 7. Return pendingChangeId to AI
return $"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.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing add_comment tool (SDK)");
throw new McpInvalidParamsException($"Error adding comment: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,139 @@
using System.ComponentModel;
using ColaFlow.Modules.Mcp.Application.DTOs;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Application.Tools.Validation;
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;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: create_issue (SDK-based implementation)
/// Creates a new Issue (Epic, Story, Task, or Bug)
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class CreateIssueSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<CreateIssueSdkTool> _logger;
public CreateIssueSdkTool(
IPendingChangeService pendingChangeService,
IProjectRepository projectRepository,
ITenantContext tenantContext,
DiffPreviewService diffPreviewService,
ILogger<CreateIssueSdkTool> 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));
}
[McpServerTool]
[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 async Task<string> CreateIssueAsync(
[Description("The ID of the project to create the issue in")] Guid projectId,
[Description("Issue title (max 200 characters)")] string title,
[Description("Issue type: Epic, Story, Task, or Bug")] string type,
[Description("Detailed issue description (optional, max 2000 characters)")] string? description = null,
[Description("Issue priority: Low, Medium, High, or Critical (defaults to Medium)")] string? priority = null,
[Description("User ID to assign the issue to (optional)")] Guid? assigneeId = null,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing create_issue tool (SDK)");
// 1. Validate input
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");
// Parse enums
if (!Enum.TryParse<IssueType>(type, ignoreCase: true, out var issueType))
throw new McpInvalidParamsException($"Invalid issue type: {type}. Must be Epic, Story, Task, or Bug");
var issuePriority = IssuePriority.Medium;
if (!string.IsNullOrEmpty(priority))
{
if (!Enum.TryParse<IssuePriority>(priority, ignoreCase: true, out issuePriority))
throw new McpInvalidParamsException($"Invalid priority: {priority}. Must be Low, Medium, High, or Critical");
}
// 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 ?? string.Empty,
type = issueType.ToString(),
priority = issuePriority.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 = "create_issue",
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 $"Issue creation request submitted for approval.\n\n" +
$"**Pending Change ID**: {pendingChange.Id}\n" +
$"**Status**: Pending Approval\n" +
$"**Issue Type**: {issueType}\n" +
$"**Title**: {title}\n" +
$"**Priority**: {issuePriority}\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.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing create_issue tool (SDK)");
throw new McpInvalidParamsException($"Error creating issue: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,122 @@
using System.ComponentModel;
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.IssueManagement.Domain.Enums;
using ColaFlow.Modules.IssueManagement.Domain.Repositories;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
/// <summary>
/// MCP Tool: update_status (SDK-based implementation)
/// Updates the status of an existing Issue
/// Generates a Diff Preview and creates a PendingChange for approval
/// </summary>
[McpServerToolType]
public class UpdateStatusSdkTool
{
private readonly IPendingChangeService _pendingChangeService;
private readonly IIssueRepository _issueRepository;
private readonly DiffPreviewService _diffPreviewService;
private readonly ILogger<UpdateStatusSdkTool> _logger;
public UpdateStatusSdkTool(
IPendingChangeService pendingChangeService,
IIssueRepository issueRepository,
DiffPreviewService diffPreviewService,
ILogger<UpdateStatusSdkTool> 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));
}
[McpServerTool]
[Description("Update the status of an existing issue. Supports workflow transitions (Backlog → Todo → InProgress → Done). Requires human approval before being applied.")]
public async Task<string> UpdateStatusAsync(
[Description("The ID of the issue to update")] Guid issueId,
[Description("The new status: Backlog, Todo, InProgress, or Done")] string newStatus,
CancellationToken cancellationToken = default)
{
try
{
_logger.LogInformation("Executing update_status tool (SDK)");
// 1. Validate and parse status
if (!Enum.TryParse<IssueStatus>(newStatus, ignoreCase: true, out var statusEnum))
throw new McpInvalidParamsException($"Invalid status: {newStatus}. Must be Backlog, Todo, InProgress, or Done");
// 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 = statusEnum.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)}"
);
// 5. Create PendingChange
var pendingChange = await _pendingChangeService.CreateAsync(
new CreatePendingChangeRequest
{
ToolName = "update_status",
Diff = diff,
ExpirationHours = 24
},
cancellationToken);
_logger.LogInformation(
"PendingChange created: {PendingChangeId} - UPDATE Issue {IssueId} status: {OldStatus} → {NewStatus}",
pendingChange.Id, issueId, oldStatus, statusEnum);
// 6. Return pendingChangeId to AI
return $"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**: {statusEnum}\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.";
}
catch (McpException)
{
throw; // Re-throw MCP exceptions as-is
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing update_status tool (SDK)");
throw new McpInvalidParamsException($"Error updating issue status: {ex.Message}");
}
}
}

View File

@@ -18,6 +18,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
<PackageReference Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="0.4.0-preview.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
<PrivateAssets>all</PrivateAssets>