feat(backend): Implement Story 5.5 - Core MCP Resources Implementation

Implemented 6 core MCP Resources for read-only AI agent access to ColaFlow data:
- projects.list - List all projects in current tenant
- projects.get/{id} - Get project details with full hierarchy
- issues.search - Search issues (Epics, Stories, Tasks) with filters
- issues.get/{id} - Get issue details (Epic/Story/Task)
- sprints.current - Get currently active Sprint(s)
- users.list - List team members in current tenant

Changes:
- Created IMcpResource interface and related DTOs (McpResourceRequest, McpResourceContent, McpResourceDescriptor)
- Implemented IMcpResourceRegistry and McpResourceRegistry for resource discovery and routing
- Created ResourcesReadMethodHandler for handling resources/read MCP method
- Updated ResourcesListMethodHandler to return actual resource catalog
- Implemented 6 concrete resource classes with multi-tenant isolation
- Registered all resources and handlers in McpServiceExtensions
- Added module references (ProjectManagement, Identity, IssueManagement domains)
- Updated package versions to 9.0.1 for consistency
- Created comprehensive unit tests (188 tests passing)
- Tests cover resource registry, URI matching, resource content generation

Technical Details:
- Multi-tenant isolation using TenantContext.GetCurrentTenantId()
- Resource URI routing supports templates (e.g., {id} parameters)
- Uses read-only repository queries (AsNoTracking) for performance
- JSON serialization with System.Text.Json
- Proper error handling with McpNotFoundException, McpInvalidParamsException
- Supports query parameters for filtering and pagination
- Auto-registration of resources at startup

Test Coverage:
- Resource registry tests (URI matching, registration, descriptors)
- Resource content generation tests
- Multi-tenant isolation verification
- All 188 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-08 21:25:28 +01:00
parent c00c909489
commit bfd8642d3c
19 changed files with 1422 additions and 8 deletions

View File

@@ -0,0 +1,152 @@
using System.Text.Json;
using ColaFlow.Modules.Mcp.Application.Resources;
using ColaFlow.Modules.Mcp.Contracts.Resources;
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
using ColaFlow.Modules.ProjectManagement.Domain.Enums;
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace ColaFlow.Modules.Mcp.Tests.Resources;
/// <summary>
/// Unit tests for ProjectsListResource
/// </summary>
public class ProjectsListResourceTests
{
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ProjectsListResource> _logger;
private readonly ProjectsListResource _sut;
private readonly Guid _tenantId = Guid.NewGuid();
public ProjectsListResourceTests()
{
_projectRepository = Substitute.For<IProjectRepository>();
_tenantContext = Substitute.For<ITenantContext>();
_logger = Substitute.For<ILogger<ProjectsListResource>>();
_tenantContext.GetCurrentTenantId().Returns(_tenantId);
_sut = new ProjectsListResource(_projectRepository, _tenantContext, _logger);
}
[Fact]
public void Uri_ReturnsCorrectValue()
{
// Assert
_sut.Uri.Should().Be("colaflow://projects.list");
}
[Fact]
public void Name_ReturnsCorrectValue()
{
// Assert
_sut.Name.Should().Be("Projects List");
}
[Fact]
public void MimeType_ReturnsApplicationJson()
{
// Assert
_sut.MimeType.Should().Be("application/json");
}
[Fact]
public async Task GetContentAsync_WithNoProjects_ReturnsEmptyList()
{
// Arrange
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
.Returns(new List<Project>());
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
// Act
var result = await _sut.GetContentAsync(request, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Uri.Should().Be("colaflow://projects.list");
result.MimeType.Should().Be("application/json");
var data = JsonSerializer.Deserialize<JsonElement>(result.Text);
data.GetProperty("projects").GetArrayLength().Should().Be(0);
data.GetProperty("total").GetInt32().Should().Be(0);
}
[Fact]
public async Task GetContentAsync_WithProjects_ReturnsProjectsList()
{
// Arrange
var project1 = Project.Create(
ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.TenantId.From(_tenantId),
"Project Alpha",
"First project",
"ALPHA",
UserId.Create());
var project2 = Project.Create(
ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.TenantId.From(_tenantId),
"Project Beta",
"Second project",
"BETA",
UserId.Create());
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
.Returns(new List<Project> { project1, project2 });
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
// Act
var result = await _sut.GetContentAsync(request, CancellationToken.None);
// Assert
result.Should().NotBeNull();
var data = JsonSerializer.Deserialize<JsonElement>(result.Text);
data.GetProperty("total").GetInt32().Should().Be(2);
var projects = data.GetProperty("projects");
projects.GetArrayLength().Should().Be(2);
var firstProject = projects[0];
firstProject.GetProperty("name").GetString().Should().Be("Project Alpha");
firstProject.GetProperty("key").GetString().Should().Be("ALPHA");
firstProject.GetProperty("status").GetString().Should().Be(ProjectStatus.Active.ToString());
}
[Fact]
public async Task GetContentAsync_CallsTenantContext()
{
// Arrange
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
.Returns(new List<Project>());
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
// Act
await _sut.GetContentAsync(request, CancellationToken.None);
// Assert
_tenantContext.Received(1).GetCurrentTenantId();
}
[Fact]
public async Task GetContentAsync_CallsRepository()
{
// Arrange
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
.Returns(new List<Project>());
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
// Act
await _sut.GetContentAsync(request, CancellationToken.None);
// Assert
await _projectRepository.Received(1).GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>());
}
}

View File

@@ -0,0 +1,144 @@
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Contracts.Resources;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace ColaFlow.Modules.Mcp.Tests.Services;
/// <summary>
/// Unit tests for McpResourceRegistry
/// </summary>
public class McpResourceRegistryTests
{
private readonly ILogger<McpResourceRegistry> _logger;
private readonly McpResourceRegistry _sut;
public McpResourceRegistryTests()
{
_logger = Substitute.For<ILogger<McpResourceRegistry>>();
_sut = new McpResourceRegistry(_logger);
}
[Fact]
public void RegisterResource_AddsResourceToRegistry()
{
// Arrange
var resource = CreateMockResource("colaflow://test", "Test Resource");
// Act
_sut.RegisterResource(resource);
// Assert
var resources = _sut.GetAllResources();
resources.Should().ContainSingle();
resources[0].Uri.Should().Be("colaflow://test");
}
[Fact]
public void RegisterResource_WithDuplicateUri_Overwrites()
{
// Arrange
var resource1 = CreateMockResource("colaflow://test", "Test Resource 1");
var resource2 = CreateMockResource("colaflow://test", "Test Resource 2");
// Act
_sut.RegisterResource(resource1);
_sut.RegisterResource(resource2);
// Assert
var resources = _sut.GetAllResources();
resources.Count.Should().Be(2); // Both are in the list
var foundResource = _sut.GetResourceByUri("colaflow://test");
foundResource!.Name.Should().Be("Test Resource 2"); // But only the last one is returned
}
[Fact]
public void GetResourceByUri_WithExactMatch_ReturnsResource()
{
// Arrange
var resource = CreateMockResource("colaflow://projects.list", "Projects List");
_sut.RegisterResource(resource);
// Act
var result = _sut.GetResourceByUri("colaflow://projects.list");
// Assert
result.Should().NotBeNull();
result!.Uri.Should().Be("colaflow://projects.list");
}
[Fact]
public void GetResourceByUri_WithTemplateMatch_ReturnsResource()
{
// Arrange
var resource = CreateMockResource("colaflow://projects.get/{id}", "Project Details");
_sut.RegisterResource(resource);
// Act
var result = _sut.GetResourceByUri("colaflow://projects.get/123");
// Assert
result.Should().NotBeNull();
result!.Uri.Should().Be("colaflow://projects.get/{id}");
}
[Fact]
public void GetResourceByUri_WithNonExistentUri_ReturnsNull()
{
// Act
var result = _sut.GetResourceByUri("colaflow://nonexistent");
// Assert
result.Should().BeNull();
}
[Fact]
public void GetResourceDescriptors_ReturnsAllDescriptors()
{
// Arrange
var resource1 = CreateMockResource("colaflow://test1", "Test 1");
var resource2 = CreateMockResource("colaflow://test2", "Test 2");
_sut.RegisterResource(resource1);
_sut.RegisterResource(resource2);
// Act
var descriptors = _sut.GetResourceDescriptors();
// Assert
descriptors.Should().HaveCount(2);
descriptors.Should().Contain(d => d.Uri == "colaflow://test1");
descriptors.Should().Contain(d => d.Uri == "colaflow://test2");
}
[Fact]
public void GetAllResources_ReturnsReadOnlyList()
{
// Arrange
var resource = CreateMockResource("colaflow://test", "Test");
_sut.RegisterResource(resource);
// Act
var resources = _sut.GetAllResources();
// Assert
resources.Should().BeAssignableTo<IReadOnlyList<IMcpResource>>();
}
private IMcpResource CreateMockResource(string uri, string name)
{
var resource = Substitute.For<IMcpResource>();
resource.Uri.Returns(uri);
resource.Name.Returns(name);
resource.Description.Returns($"Description for {name}");
resource.MimeType.Returns("application/json");
resource.GetContentAsync(Arg.Any<McpResourceRequest>(), Arg.Any<CancellationToken>())
.Returns(new McpResourceContent
{
Uri = uri,
MimeType = "application/json",
Text = "{}"
});
return resource;
}
}