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