feat(backend): Implement Story 5.6 - Resource Registration & Discovery

Implemented pluggable resource registration and auto-discovery mechanism for MCP Resources.

Changes:
- Enhanced McpResourceDescriptor with metadata (Category, Version, Parameters, Examples, Tags, IsEnabled)
- Created ResourceDiscoveryService for Assembly scanning and auto-discovery
- Updated McpResourceRegistry with category support and grouping methods
- Enhanced ResourcesListMethodHandler to return categorized resources with full metadata
- Created ResourceHealthCheckHandler for resource availability verification
- Updated all existing Resources (Projects, Issues, Sprints, Users) with Categories and Versions
- Updated McpServiceExtensions to use auto-discovery at startup
- Added comprehensive unit tests for discovery and health check

Features:
 New Resources automatically discovered via Assembly scanning
 Resources organized by category (Projects, Issues, Sprints, Users)
 Rich metadata for documentation (parameters, examples, tags)
 Health check endpoint (resources/health) for monitoring
 Thread-safe registry operations

🤖 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-09 16:07:50 +01:00
parent bfd8642d3c
commit 3ab505e0f6
18 changed files with 814 additions and 42 deletions

View File

@@ -0,0 +1,94 @@
using ColaFlow.Modules.Mcp.Application.Handlers;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Contracts.Resources;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace ColaFlow.Modules.Mcp.Tests.Handlers;
public class ResourceHealthCheckHandlerTests
{
private readonly ILogger<ResourceHealthCheckHandler> _mockLogger;
private readonly IMcpResourceRegistry _mockRegistry;
private readonly ResourceHealthCheckHandler _handler;
public ResourceHealthCheckHandlerTests()
{
_mockLogger = Substitute.For<ILogger<ResourceHealthCheckHandler>>();
_mockRegistry = Substitute.For<IMcpResourceRegistry>();
_handler = new ResourceHealthCheckHandler(_mockLogger, _mockRegistry);
}
[Fact]
public void MethodName_ShouldBeResourcesHealth()
{
// Assert
_handler.MethodName.Should().Be("resources/health");
}
[Fact]
public async Task HandleAsync_WithHealthyResources_ShouldReturnHealthyStatus()
{
// Arrange
var mockResource = Substitute.For<IMcpResource>();
mockResource.Uri.Returns("test://resource");
mockResource.Name.Returns("Test Resource");
mockResource.Category.Returns("Testing");
mockResource.GetDescriptor().Returns(new McpResourceDescriptor
{
Uri = "test://resource",
Name = "Test Resource",
Category = "Testing",
IsEnabled = true,
Version = "1.0"
});
_mockRegistry.GetAllResources()
.Returns(new List<IMcpResource> { mockResource }.AsReadOnly());
// Act
var result = await _handler.HandleAsync(null, CancellationToken.None);
// Assert
result.Should().NotBeNull();
var response = result as dynamic;
((string)response.status).Should().Be("healthy");
((int)response.totalResources).Should().Be(1);
((int)response.healthyResources).Should().Be(1);
((int)response.unhealthyResources).Should().Be(0);
}
[Fact]
public async Task HandleAsync_WithUnhealthyResource_ShouldReturnDegradedStatus()
{
// Arrange
var mockResource = Substitute.For<IMcpResource>();
mockResource.Uri.Returns("test://resource");
mockResource.Name.Returns("Test Resource");
mockResource.Category.Returns("Testing");
mockResource.GetDescriptor().Returns(new McpResourceDescriptor
{
Uri = "", // Invalid - empty URI
Name = "Test Resource",
Category = "Testing",
IsEnabled = true,
Version = "1.0"
});
_mockRegistry.GetAllResources()
.Returns(new List<IMcpResource> { mockResource }.AsReadOnly());
// Act
var result = await _handler.HandleAsync(null, CancellationToken.None);
// Assert
result.Should().NotBeNull();
var response = result as dynamic;
((string)response.status).Should().Be("degraded");
((int)response.totalResources).Should().Be(1);
((int)response.healthyResources).Should().Be(0);
((int)response.unhealthyResources).Should().Be(1);
}
}

View File

@@ -128,12 +128,12 @@ public class McpResourceRegistryTests
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.UriReturns(uri);
resource.NameReturns(name);
resource.DescriptionReturns($"Description for {name}");
resource.MimeTypeReturns("application/json");
resource.GetContentAsync(Arg.Any<McpResourceRequest>(), Arg.Any<CancellationToken>())
.Returns(new McpResourceContent
Returns(new McpResourceContent
{
Uri = uri,
MimeType = "application/json",
@@ -141,4 +141,154 @@ public class McpResourceRegistryTests
});
return resource;
}
[Fact]
public void GetCategories_ShouldReturnAllUniqueCategories()
{
// Arrange
var mockLogger = Substitute.For<ILogger<McpResourceRegistry>>();
var registry = new McpResourceRegistry(mockLogger);
var resource1 = Substitute.For<IMcpResource>();
resource1.Setup(r => r.Uri)Returns("test://resource1");
resource1.Setup(r => r.Name)Returns("Resource 1");
resource1.Setup(r => r.Category)Returns("Projects");
var resource2 = Substitute.For<IMcpResource>();
resource2.Setup(r => r.Uri)Returns("test://resource2");
resource2.Setup(r => r.Name)Returns("Resource 2");
resource2.Setup(r => r.Category)Returns("Issues");
var resource3 = Substitute.For<IMcpResource>();
resource3.Setup(r => r.Uri)Returns("test://resource3");
resource3.Setup(r => r.Name)Returns("Resource 3");
resource3.Setup(r => r.Category)Returns("Projects"); // Duplicate category
registry.RegisterResource(resource1.Object);
registry.RegisterResource(resource2.Object);
registry.RegisterResource(resource3.Object);
// Act
var categories = registry.GetCategories();
// Assert
Assert.Equal(2, categories.Count);
Assert.Contains("Projects", categories);
Assert.Contains("Issues", categories);
}
[Fact]
public void GetResourcesByCategory_ShouldReturnOnlyResourcesInCategory()
{
// Arrange
var mockLogger = Substitute.For<ILogger<McpResourceRegistry>>();
var registry = new McpResourceRegistry(mockLogger);
var projectResource1 = Substitute.For<IMcpResource>();
projectResource1.Setup(r => r.Uri)Returns("test://project1");
projectResource1.Setup(r => r.Name)Returns("Project 1");
projectResource1.Setup(r => r.Category)Returns("Projects");
var projectResource2 = Substitute.For<IMcpResource>();
projectResource2.Setup(r => r.Uri)Returns("test://project2");
projectResource2.Setup(r => r.Name)Returns("Project 2");
projectResource2.Setup(r => r.Category)Returns("Projects");
var issueResource = Substitute.For<IMcpResource>();
issueResource.Setup(r => r.Uri)Returns("test://issue1");
issueResource.Setup(r => r.Name)Returns("Issue 1");
issueResource.Setup(r => r.Category)Returns("Issues");
registry.RegisterResource(projectResource1.Object);
registry.RegisterResource(projectResource2.Object);
registry.RegisterResource(issueResource.Object);
// Act
var projectResources = registry.GetResourcesByCategory("Projects");
// Assert
Assert.Equal(2, projectResources.Count);
Assert.All(projectResources, r => Assert.Equal("Projects", r.Category));
}
[Fact]
public void GetResourcesGroupedByCategory_ShouldGroupCorrectly()
{
// Arrange
var mockLogger = Substitute.For<ILogger<McpResourceRegistry>>();
var registry = new McpResourceRegistry(mockLogger);
var resource1 = Substitute.For<IMcpResource>();
resource1.Setup(r => r.Uri)Returns("test://resource1");
resource1.Setup(r => r.Name)Returns("Resource 1");
resource1.Setup(r => r.Category)Returns("Projects");
var resource2 = Substitute.For<IMcpResource>();
resource2.Setup(r => r.Uri)Returns("test://resource2");
resource2.Setup(r => r.Name)Returns("Resource 2");
resource2.Setup(r => r.Category)Returns("Issues");
var resource3 = Substitute.For<IMcpResource>();
resource3.Setup(r => r.Uri)Returns("test://resource3");
resource3.Setup(r => r.Name)Returns("Resource 3");
resource3.Setup(r => r.Category)Returns("Projects");
registry.RegisterResource(resource1.Object);
registry.RegisterResource(resource2.Object);
registry.RegisterResource(resource3.Object);
// Act
var grouped = registry.GetResourcesGroupedByCategory();
// Assert
Assert.Equal(2, grouped.Count);
Assert.True(grouped.ContainsKey("Projects"));
Assert.True(grouped.ContainsKey("Issues"));
Assert.Equal(2, grouped["Projects"].Count);
Assert.Single(grouped["Issues"]);
}
[Fact]
public void GetDescriptor_ShouldIncludeEnhancedMetadata()
{
// Arrange
var mockLogger = Substitute.For<ILogger<McpResourceRegistry>>();
var registry = new McpResourceRegistry(mockLogger);
var mockResource = Substitute.For<IMcpResource>();
mockResource(r => r.Uri)Returns("test://resource");
mockResource(r => r.Name)Returns("Test Resource");
mockResource(r => r.Description)Returns("Test description");
mockResource(r => r.MimeType)Returns("application/json");
mockResource(r => r.Category)Returns("Testing");
mockResource(r => r.Version)Returns("1.0");
mockResource(r => r.GetDescriptor())Returns(new McpResourceDescriptor
{
Uri = "test://resource",
Name = "Test Resource",
Description = "Test description",
MimeType = "application/json",
Category = "Testing",
Version = "1.0",
Parameters = new Dictionary<string, string> { { "id", "Resource ID" } },
Examples = new List<string> { "GET test://resource?id=123" },
Tags = new List<string> { "test", "example" },
IsEnabled = true
});
registry.RegisterResource(mockResource);
// Act
var descriptors = registry.GetResourceDescriptors();
// Assert
Assert.Single(descriptors);
var descriptor = descriptors[0];
Assert.Equal("Testing", descriptor.Category);
Assert.Equal("1.0", descriptor.Version);
Assert.NotNull(descriptor.Parameters);
Assert.NotNull(descriptor.Examples);
Assert.NotNull(descriptor.Tags);
Assert.True(descriptor.IsEnabled);
}
}

View File

@@ -0,0 +1,72 @@
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Contracts.Resources;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace ColaFlow.Modules.Mcp.Tests.Services;
public class ResourceDiscoveryServiceTests
{
private readonly IResourceDiscoveryService _discoveryService;
private readonly ILogger<ResourceDiscoveryService> _mockLogger;
public ResourceDiscoveryServiceTests()
{
_mockLogger = Substitute.For<ILogger<ResourceDiscoveryService>>();
_discoveryService = new ResourceDiscoveryService(_mockLogger);
}
[Fact]
public void DiscoverResourceTypes_ShouldFindAllResourceImplementations()
{
// Act
var resourceTypes = _discoveryService.DiscoverResourceTypes();
// Assert
resourceTypes.Should().NotBeEmpty();
resourceTypes.Should().AllSatisfy(type =>
{
typeof(IMcpResource).IsAssignableFrom(type).Should().BeTrue();
type.IsInterface.Should().BeFalse();
type.IsAbstract.Should().BeFalse();
});
}
[Fact]
public void DiscoverResourceTypes_ShouldFindKnownResources()
{
// Act
var resourceTypes = _discoveryService.DiscoverResourceTypes();
// Assert - should find at least these known resources
var typeNames = resourceTypes.Select(t => t.Name).ToList();
typeNames.Should().Contain("ProjectsListResource");
typeNames.Should().Contain("ProjectsGetResource");
typeNames.Should().Contain("IssuesSearchResource");
typeNames.Should().Contain("IssuesGetResource");
typeNames.Should().Contain("SprintsCurrentResource");
typeNames.Should().Contain("UsersListResource");
}
[Fact]
public void DiscoverResourceTypes_ShouldNotIncludeInterfaces()
{
// Act
var resourceTypes = _discoveryService.DiscoverResourceTypes();
// Assert
resourceTypes.Should().NotContain(t => t.IsInterface);
}
[Fact]
public void DiscoverResourceTypes_ShouldNotIncludeAbstractClasses()
{
// Act
var resourceTypes = _discoveryService.DiscoverResourceTypes();
// Assert
resourceTypes.Should().NotContain(t => t.IsAbstract);
}
}