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>
295 lines
10 KiB
C#
295 lines
10 KiB
C#
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.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
|
|
{
|
|
Uri = uri,
|
|
MimeType = "application/json",
|
|
Text = "{}"
|
|
});
|
|
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);
|
|
}
|
|
}
|