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,100 @@
|
|||||||
|
using ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for 'resources/health' method
|
||||||
|
/// Checks availability and health of all registered resources
|
||||||
|
/// </summary>
|
||||||
|
public class ResourceHealthCheckHandler : IMcpMethodHandler
|
||||||
|
{
|
||||||
|
private readonly ILogger<ResourceHealthCheckHandler> _logger;
|
||||||
|
private readonly IMcpResourceRegistry _resourceRegistry;
|
||||||
|
|
||||||
|
public string MethodName => "resources/health";
|
||||||
|
|
||||||
|
public ResourceHealthCheckHandler(
|
||||||
|
ILogger<ResourceHealthCheckHandler> logger,
|
||||||
|
IMcpResourceRegistry resourceRegistry)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_resourceRegistry = resourceRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Handling resources/health request");
|
||||||
|
|
||||||
|
var resources = _resourceRegistry.GetAllResources();
|
||||||
|
var healthResults = new List<object>();
|
||||||
|
var totalResources = resources.Count;
|
||||||
|
var healthyResources = 0;
|
||||||
|
var unhealthyResources = 0;
|
||||||
|
|
||||||
|
foreach (var resource in resources)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Try to get descriptor - if this fails, resource is unhealthy
|
||||||
|
var descriptor = resource.GetDescriptor();
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
var isHealthy = !string.IsNullOrWhiteSpace(descriptor.Uri)
|
||||||
|
&& !string.IsNullOrWhiteSpace(descriptor.Name)
|
||||||
|
&& descriptor.IsEnabled;
|
||||||
|
|
||||||
|
if (isHealthy)
|
||||||
|
{
|
||||||
|
healthyResources++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unhealthyResources++;
|
||||||
|
}
|
||||||
|
|
||||||
|
healthResults.Add(new
|
||||||
|
{
|
||||||
|
uri = descriptor.Uri,
|
||||||
|
name = descriptor.Name,
|
||||||
|
category = descriptor.Category,
|
||||||
|
status = isHealthy ? "healthy" : "unhealthy",
|
||||||
|
isEnabled = descriptor.IsEnabled,
|
||||||
|
version = descriptor.Version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
unhealthyResources++;
|
||||||
|
_logger.LogError(ex, "Health check failed for resource {ResourceType}", resource.GetType().Name);
|
||||||
|
|
||||||
|
healthResults.Add(new
|
||||||
|
{
|
||||||
|
uri = resource.Uri,
|
||||||
|
name = resource.Name,
|
||||||
|
category = resource.Category,
|
||||||
|
status = "error",
|
||||||
|
error = ex.Message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var overallStatus = unhealthyResources == 0 ? "healthy" : "degraded";
|
||||||
|
|
||||||
|
_logger.LogInformation("Resource health check completed: {Healthy}/{Total} healthy",
|
||||||
|
healthyResources, totalResources);
|
||||||
|
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
status = overallStatus,
|
||||||
|
totalResources = totalResources,
|
||||||
|
healthyResources = healthyResources,
|
||||||
|
unhealthyResources = unhealthyResources,
|
||||||
|
timestamp = DateTime.UtcNow,
|
||||||
|
resources = healthResults
|
||||||
|
};
|
||||||
|
|
||||||
|
return await Task.FromResult<object?>(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handler for the 'resources/list' MCP method
|
/// Handler for the 'resources/list' MCP method
|
||||||
|
/// Returns categorized list of all available resources with full metadata
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ResourcesListMethodHandler : IMcpMethodHandler
|
public class ResourcesListMethodHandler : IMcpMethodHandler
|
||||||
{
|
{
|
||||||
@@ -25,10 +26,32 @@ public class ResourcesListMethodHandler : IMcpMethodHandler
|
|||||||
{
|
{
|
||||||
_logger.LogDebug("Handling resources/list request");
|
_logger.LogDebug("Handling resources/list request");
|
||||||
|
|
||||||
// Get all registered resource descriptors
|
// Get all registered resource descriptors with full metadata
|
||||||
var descriptors = _resourceRegistry.GetResourceDescriptors();
|
var descriptors = _resourceRegistry.GetResourceDescriptors();
|
||||||
|
var categories = _resourceRegistry.GetCategories();
|
||||||
|
|
||||||
_logger.LogInformation("Returning {Count} MCP resources", descriptors.Count);
|
_logger.LogInformation("Returning {Count} MCP resources in {CategoryCount} categories",
|
||||||
|
descriptors.Count, categories.Count);
|
||||||
|
|
||||||
|
// Group by category for better organization
|
||||||
|
var resourcesByCategory = descriptors
|
||||||
|
.GroupBy(d => d.Category)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(d => new
|
||||||
|
{
|
||||||
|
uri = d.Uri,
|
||||||
|
name = d.Name,
|
||||||
|
description = d.Description,
|
||||||
|
mimeType = d.MimeType,
|
||||||
|
version = d.Version,
|
||||||
|
parameters = d.Parameters,
|
||||||
|
examples = d.Examples,
|
||||||
|
tags = d.Tags,
|
||||||
|
isEnabled = d.IsEnabled
|
||||||
|
}).ToArray()
|
||||||
|
);
|
||||||
|
|
||||||
var response = new
|
var response = new
|
||||||
{
|
{
|
||||||
@@ -37,8 +60,18 @@ public class ResourcesListMethodHandler : IMcpMethodHandler
|
|||||||
uri = d.Uri,
|
uri = d.Uri,
|
||||||
name = d.Name,
|
name = d.Name,
|
||||||
description = d.Description,
|
description = d.Description,
|
||||||
mimeType = d.MimeType
|
mimeType = d.MimeType,
|
||||||
}).ToArray()
|
category = d.Category,
|
||||||
|
version = d.Version,
|
||||||
|
parameters = d.Parameters,
|
||||||
|
examples = d.Examples,
|
||||||
|
tags = d.Tags,
|
||||||
|
isEnabled = d.IsEnabled
|
||||||
|
}).ToArray(),
|
||||||
|
categories = categories.ToArray(),
|
||||||
|
resourcesByCategory = resourcesByCategory,
|
||||||
|
totalCount = descriptors.Count,
|
||||||
|
categoryCount = categories.Count
|
||||||
};
|
};
|
||||||
|
|
||||||
return Task.FromResult<object?>(response);
|
return Task.FromResult<object?>(response);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public class IssuesGetResource : IMcpResource
|
|||||||
public string Name => "Issue Details";
|
public string Name => "Issue Details";
|
||||||
public string Description => "Get detailed information about an issue (Epic/Story/Task)";
|
public string Description => "Get detailed information about an issue (Epic/Story/Task)";
|
||||||
public string MimeType => "application/json";
|
public string MimeType => "application/json";
|
||||||
|
public string Category => "Issues";
|
||||||
|
public string Version => "1.0";
|
||||||
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public class IssuesSearchResource : IMcpResource
|
|||||||
public string Name => "Issues Search";
|
public string Name => "Issues Search";
|
||||||
public string Description => "Search issues with filters (status, priority, assignee, etc.)";
|
public string Description => "Search issues with filters (status, priority, assignee, etc.)";
|
||||||
public string MimeType => "application/json";
|
public string MimeType => "application/json";
|
||||||
|
public string Category => "Issues";
|
||||||
|
public string Version => "1.0";
|
||||||
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public class ProjectsGetResource : IMcpResource
|
|||||||
public string Name => "Project Details";
|
public string Name => "Project Details";
|
||||||
public string Description => "Get detailed information about a project";
|
public string Description => "Get detailed information about a project";
|
||||||
public string MimeType => "application/json";
|
public string MimeType => "application/json";
|
||||||
|
public string Category => "Projects";
|
||||||
|
public string Version => "1.0";
|
||||||
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
@@ -33,6 +35,30 @@ public class ProjectsGetResource : IMcpResource
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public McpResourceDescriptor GetDescriptor()
|
||||||
|
{
|
||||||
|
return new McpResourceDescriptor
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
Name = Name,
|
||||||
|
Description = Description,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Category = Category,
|
||||||
|
Version = Version,
|
||||||
|
Parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "id", "Project ID (GUID)" }
|
||||||
|
},
|
||||||
|
Examples = new List<string>
|
||||||
|
{
|
||||||
|
"GET colaflow://projects.get/123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"Returns: { id, name, key, description, status, epics: [...] }"
|
||||||
|
},
|
||||||
|
Tags = new List<string> { "projects", "details", "read-only" },
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<McpResourceContent> GetContentAsync(
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
McpResourceRequest request,
|
McpResourceRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class ProjectsListResource : IMcpResource
|
|||||||
public string Name => "Projects List";
|
public string Name => "Projects List";
|
||||||
public string Description => "List all projects in current tenant";
|
public string Description => "List all projects in current tenant";
|
||||||
public string MimeType => "application/json";
|
public string MimeType => "application/json";
|
||||||
|
public string Category => "Projects";
|
||||||
|
public string Version => "1.0";
|
||||||
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
@@ -31,6 +33,27 @@ public class ProjectsListResource : IMcpResource
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public McpResourceDescriptor GetDescriptor()
|
||||||
|
{
|
||||||
|
return new McpResourceDescriptor
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
Name = Name,
|
||||||
|
Description = Description,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Category = Category,
|
||||||
|
Version = Version,
|
||||||
|
Parameters = null, // No parameters required
|
||||||
|
Examples = new List<string>
|
||||||
|
{
|
||||||
|
"GET colaflow://projects.list",
|
||||||
|
"Returns: { projects: [...], total: N }"
|
||||||
|
},
|
||||||
|
Tags = new List<string> { "projects", "list", "read-only" },
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<McpResourceContent> GetContentAsync(
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
McpResourceRequest request,
|
McpResourceRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public class SprintsCurrentResource : IMcpResource
|
|||||||
public string Name => "Current Sprint";
|
public string Name => "Current Sprint";
|
||||||
public string Description => "Get the currently active Sprint(s)";
|
public string Description => "Get the currently active Sprint(s)";
|
||||||
public string MimeType => "application/json";
|
public string MimeType => "application/json";
|
||||||
|
public string Category => "Sprints";
|
||||||
|
public string Version => "1.0";
|
||||||
|
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public class UsersListResource : IMcpResource
|
|||||||
public string Name => "Team Members";
|
public string Name => "Team Members";
|
||||||
public string Description => "List all team members in current tenant";
|
public string Description => "List all team members in current tenant";
|
||||||
public string MimeType => "application/json";
|
public string MimeType => "application/json";
|
||||||
|
public string Category => "Users";
|
||||||
|
public string Version => "1.0";
|
||||||
|
|
||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registry for all MCP Resources
|
/// Registry for all MCP Resources
|
||||||
/// Manages resource discovery and routing
|
/// Manages resource discovery, routing, and categorization
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMcpResourceRegistry
|
public interface IMcpResourceRegistry
|
||||||
{
|
{
|
||||||
@@ -27,4 +27,19 @@ public interface IMcpResourceRegistry
|
|||||||
/// Get all resource descriptors (for resources/list method)
|
/// Get all resource descriptors (for resources/list method)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IReadOnlyList<McpResourceDescriptor> GetResourceDescriptors();
|
IReadOnlyList<McpResourceDescriptor> GetResourceDescriptors();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resources by category
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<IMcpResource> GetResourcesByCategory(string category);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all categories
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<string> GetCategories();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resources grouped by category
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyDictionary<string, List<IMcpResource>> GetResourcesGroupedByCategory();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for discovering MCP Resources via Assembly scanning
|
||||||
|
/// </summary>
|
||||||
|
public interface IResourceDiscoveryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Discover all IMcpResource implementations in loaded assemblies
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>List of discovered resource types</returns>
|
||||||
|
IReadOnlyList<Type> DiscoverResourceTypes();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discover and instantiate all resources
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceProvider">Service provider for dependency injection</param>
|
||||||
|
/// <returns>List of instantiated resources</returns>
|
||||||
|
IReadOnlyList<IMcpResource> DiscoverAndInstantiateResources(IServiceProvider serviceProvider);
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implementation of MCP Resource Registry
|
/// Implementation of MCP Resource Registry
|
||||||
|
/// Enhanced with category support and dynamic registration
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class McpResourceRegistry : IMcpResourceRegistry
|
public class McpResourceRegistry : IMcpResourceRegistry
|
||||||
{
|
{
|
||||||
private readonly ILogger<McpResourceRegistry> _logger;
|
private readonly ILogger<McpResourceRegistry> _logger;
|
||||||
private readonly Dictionary<string, IMcpResource> _resources = new();
|
private readonly Dictionary<string, IMcpResource> _resources = new();
|
||||||
private readonly List<IMcpResource> _resourceList = new();
|
private readonly List<IMcpResource> _resourceList = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
public McpResourceRegistry(ILogger<McpResourceRegistry> logger)
|
public McpResourceRegistry(ILogger<McpResourceRegistry> logger)
|
||||||
{
|
{
|
||||||
@@ -20,51 +22,106 @@ public class McpResourceRegistry : IMcpResourceRegistry
|
|||||||
|
|
||||||
public void RegisterResource(IMcpResource resource)
|
public void RegisterResource(IMcpResource resource)
|
||||||
{
|
{
|
||||||
if (_resources.ContainsKey(resource.Uri))
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
|
if (_resources.ContainsKey(resource.Uri))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
|
||||||
|
_resourceList.Remove(_resources[resource.Uri]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_resources[resource.Uri] = resource;
|
||||||
|
_resourceList.Add(resource);
|
||||||
|
|
||||||
|
_logger.LogInformation("Registered MCP Resource: {Uri} - {Name} [{Category}]",
|
||||||
|
resource.Uri, resource.Name, resource.Category);
|
||||||
}
|
}
|
||||||
|
|
||||||
_resources[resource.Uri] = resource;
|
|
||||||
_resourceList.Add(resource);
|
|
||||||
|
|
||||||
_logger.LogInformation("Registered MCP Resource: {Uri} - {Name}", resource.Uri, resource.Name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<IMcpResource> GetAllResources()
|
public IReadOnlyList<IMcpResource> GetAllResources()
|
||||||
{
|
{
|
||||||
return _resourceList.AsReadOnly();
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _resourceList.AsReadOnly();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IMcpResource? GetResourceByUri(string uri)
|
public IMcpResource? GetResourceByUri(string uri)
|
||||||
{
|
{
|
||||||
// Try exact match first
|
lock (_lock)
|
||||||
if (_resources.TryGetValue(uri, out var resource))
|
|
||||||
{
|
{
|
||||||
return resource;
|
// Try exact match first
|
||||||
}
|
if (_resources.TryGetValue(uri, out var resource))
|
||||||
|
|
||||||
// Try matching against URI templates (e.g., "colaflow://projects.get/{id}")
|
|
||||||
foreach (var registeredResource in _resourceList)
|
|
||||||
{
|
|
||||||
if (UriMatchesTemplate(uri, registeredResource.Uri))
|
|
||||||
{
|
{
|
||||||
return registeredResource;
|
return resource;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
// Try matching against URI templates (e.g., "colaflow://projects.get/{id}")
|
||||||
|
foreach (var registeredResource in _resourceList)
|
||||||
|
{
|
||||||
|
if (UriMatchesTemplate(uri, registeredResource.Uri))
|
||||||
|
{
|
||||||
|
return registeredResource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<McpResourceDescriptor> GetResourceDescriptors()
|
public IReadOnlyList<McpResourceDescriptor> GetResourceDescriptors()
|
||||||
{
|
{
|
||||||
return _resourceList.Select(r => new McpResourceDescriptor
|
lock (_lock)
|
||||||
{
|
{
|
||||||
Uri = r.Uri,
|
return _resourceList
|
||||||
Name = r.Name,
|
.Select(r => r.GetDescriptor())
|
||||||
Description = r.Description,
|
.ToList()
|
||||||
MimeType = r.MimeType
|
.AsReadOnly();
|
||||||
}).ToList().AsReadOnly();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resources by category
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<IMcpResource> GetResourcesByCategory(string category)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _resourceList
|
||||||
|
.Where(r => r.Category.Equals(category, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all categories
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> GetCategories()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _resourceList
|
||||||
|
.Select(r => r.Category)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(c => c)
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resources grouped by category
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyDictionary<string, List<IMcpResource>> GetResourcesGroupedByCategory()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _resourceList
|
||||||
|
.GroupBy(r => r.Category)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList())
|
||||||
|
.AsReadOnly();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of Resource Discovery Service
|
||||||
|
/// Scans assemblies to find all IMcpResource implementations
|
||||||
|
/// </summary>
|
||||||
|
public class ResourceDiscoveryService : IResourceDiscoveryService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ResourceDiscoveryService> _logger;
|
||||||
|
|
||||||
|
public ResourceDiscoveryService(ILogger<ResourceDiscoveryService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Type> DiscoverResourceTypes()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting MCP Resource discovery via Assembly scanning...");
|
||||||
|
|
||||||
|
var resourceTypes = new List<Type>();
|
||||||
|
|
||||||
|
// Get all loaded assemblies
|
||||||
|
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Where(a => !a.IsDynamic && a.FullName != null && a.FullName.StartsWith("ColaFlow"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogDebug("Scanning {Count} assemblies for IMcpResource implementations", assemblies.Count);
|
||||||
|
|
||||||
|
foreach (var assembly in assemblies)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var types = assembly.GetTypes()
|
||||||
|
.Where(t => typeof(IMcpResource).IsAssignableFrom(t)
|
||||||
|
&& !t.IsInterface
|
||||||
|
&& !t.IsAbstract
|
||||||
|
&& t.IsClass)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (types.Any())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Found {Count} resources in assembly {Assembly}",
|
||||||
|
types.Count, assembly.GetName().Name);
|
||||||
|
resourceTypes.AddRange(types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ReflectionTypeLoadException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load types from assembly {Assembly}", assembly.FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Discovered {Count} MCP Resource types", resourceTypes.Count);
|
||||||
|
|
||||||
|
return resourceTypes.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IMcpResource> DiscoverAndInstantiateResources(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
var resourceTypes = DiscoverResourceTypes();
|
||||||
|
var resources = new List<IMcpResource>();
|
||||||
|
|
||||||
|
foreach (var resourceType in resourceTypes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use DI to instantiate the resource (resolves dependencies automatically)
|
||||||
|
var resource = ActivatorUtilities.CreateInstance(serviceProvider, resourceType) as IMcpResource;
|
||||||
|
|
||||||
|
if (resource != null)
|
||||||
|
{
|
||||||
|
resources.Add(resource);
|
||||||
|
_logger.LogDebug("Instantiated resource: {ResourceType} -> {Uri}",
|
||||||
|
resourceType.Name, resource.Uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to instantiate resource type {ResourceType}", resourceType.FullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Instantiated {Count} MCP Resources", resources.Count);
|
||||||
|
|
||||||
|
return resources.AsReadOnly();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,35 @@ public interface IMcpResource
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
string MimeType { get; }
|
string MimeType { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource category (e.g., "Projects", "Issues", "Sprints", "Users")
|
||||||
|
/// Default: "General"
|
||||||
|
/// </summary>
|
||||||
|
string Category => "General";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource version (for future compatibility)
|
||||||
|
/// Default: "1.0"
|
||||||
|
/// </summary>
|
||||||
|
string Version => "1.0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resource descriptor with full metadata
|
||||||
|
/// </summary>
|
||||||
|
McpResourceDescriptor GetDescriptor()
|
||||||
|
{
|
||||||
|
return new McpResourceDescriptor
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
Name = Name,
|
||||||
|
Description = Description,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Category = Category,
|
||||||
|
Version = Version,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get resource content
|
/// Get resource content
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ namespace ColaFlow.Modules.Mcp.Contracts.Resources;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Descriptor for an MCP Resource (used in resources/list)
|
/// Descriptor for an MCP Resource (used in resources/list)
|
||||||
|
/// Enhanced with metadata for better discovery and documentation
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class McpResourceDescriptor
|
public class McpResourceDescriptor
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resource URI
|
/// Resource URI (e.g., "colaflow://projects.list")
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Uri { get; set; } = string.Empty;
|
public string Uri { get; set; } = string.Empty;
|
||||||
|
|
||||||
@@ -21,7 +22,38 @@ public class McpResourceDescriptor
|
|||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MIME type
|
/// MIME type (default: "application/json")
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string MimeType { get; set; } = "application/json";
|
public string MimeType { get; set; } = "application/json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource category for organization (e.g., "Projects", "Issues", "Sprints", "Users")
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; set; } = "General";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource version (for future compatibility)
|
||||||
|
/// </summary>
|
||||||
|
public string Version { get; set; } = "1.0";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parameters accepted by this resource (for documentation)
|
||||||
|
/// Key: parameter name, Value: parameter description
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string>? Parameters { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Usage examples (for documentation)
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? Examples { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tags for additional categorization
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? Tags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this resource is enabled
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.Mcp.Infrastructure.Extensions;
|
namespace ColaFlow.Modules.Mcp.Infrastructure.Extensions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extension methods for registering MCP services
|
/// Extension methods for registering MCP services
|
||||||
|
/// Enhanced with auto-discovery and dynamic registration
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class McpServiceExtensions
|
public static class McpServiceExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers MCP module services
|
/// Registers MCP module services with auto-discovery
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddMcpModule(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddMcpModule(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
@@ -39,7 +41,10 @@ public static class McpServiceExtensions
|
|||||||
// Register resource registry (Singleton - shared across all requests)
|
// Register resource registry (Singleton - shared across all requests)
|
||||||
services.AddSingleton<IMcpResourceRegistry, McpResourceRegistry>();
|
services.AddSingleton<IMcpResourceRegistry, McpResourceRegistry>();
|
||||||
|
|
||||||
// Register MCP Resources
|
// Register Resource Discovery Service (Singleton - used at startup)
|
||||||
|
services.AddSingleton<IResourceDiscoveryService, ResourceDiscoveryService>();
|
||||||
|
|
||||||
|
// Register MCP Resources (Scoped - manual registration for now, auto-discovery at startup)
|
||||||
services.AddScoped<IMcpResource, ProjectsListResource>();
|
services.AddScoped<IMcpResource, ProjectsListResource>();
|
||||||
services.AddScoped<IMcpResource, ProjectsGetResource>();
|
services.AddScoped<IMcpResource, ProjectsGetResource>();
|
||||||
services.AddScoped<IMcpResource, IssuesSearchResource>();
|
services.AddScoped<IMcpResource, IssuesSearchResource>();
|
||||||
@@ -54,6 +59,7 @@ public static class McpServiceExtensions
|
|||||||
services.AddScoped<IMcpMethodHandler, InitializeMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, InitializeMethodHandler>();
|
||||||
services.AddScoped<IMcpMethodHandler, ResourcesListMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ResourcesListMethodHandler>();
|
||||||
services.AddScoped<IMcpMethodHandler, ResourcesReadMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ResourcesReadMethodHandler>();
|
||||||
|
services.AddScoped<IMcpMethodHandler, ResourceHealthCheckHandler>(); // NEW: Health check handler
|
||||||
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
|
||||||
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
|
||||||
|
|
||||||
@@ -71,7 +77,7 @@ public static class McpServiceExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IApplicationBuilder UseMcpMiddleware(this IApplicationBuilder app)
|
public static IApplicationBuilder UseMcpMiddleware(this IApplicationBuilder app)
|
||||||
{
|
{
|
||||||
// Initialize resource registry (register all resources)
|
// Initialize resource registry (register all resources with auto-discovery)
|
||||||
InitializeResourceRegistry(app);
|
InitializeResourceRegistry(app);
|
||||||
|
|
||||||
// 1. Correlation ID middleware (FIRST - needed for all subsequent logging)
|
// 1. Correlation ID middleware (FIRST - needed for all subsequent logging)
|
||||||
@@ -94,17 +100,30 @@ public static class McpServiceExtensions
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initialize resource registry by registering all resources
|
/// Initialize resource registry by registering all resources
|
||||||
|
/// Enhanced with auto-discovery using Assembly scanning
|
||||||
/// This is called once at startup
|
/// This is called once at startup
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void InitializeResourceRegistry(IApplicationBuilder app)
|
private static void InitializeResourceRegistry(IApplicationBuilder app)
|
||||||
{
|
{
|
||||||
using var scope = app.ApplicationServices.CreateScope();
|
using var scope = app.ApplicationServices.CreateScope();
|
||||||
var registry = scope.ServiceProvider.GetRequiredService<IMcpResourceRegistry>();
|
var registry = scope.ServiceProvider.GetRequiredService<IMcpResourceRegistry>();
|
||||||
var resources = scope.ServiceProvider.GetServices<IMcpResource>();
|
var discoveryService = scope.ServiceProvider.GetRequiredService<IResourceDiscoveryService>();
|
||||||
|
var loggerFactory = scope.ServiceProvider.GetRequiredService<ILoggerFactory>();
|
||||||
|
var logger = loggerFactory.CreateLogger("McpResourceRegistry");
|
||||||
|
|
||||||
|
logger.LogInformation("Initializing MCP Resource Registry with auto-discovery...");
|
||||||
|
|
||||||
|
// Auto-discover and instantiate all resources
|
||||||
|
var resources = discoveryService.DiscoverAndInstantiateResources(scope.ServiceProvider);
|
||||||
|
|
||||||
|
// Register all discovered resources
|
||||||
foreach (var resource in resources)
|
foreach (var resource in resources)
|
||||||
{
|
{
|
||||||
registry.RegisterResource(resource);
|
registry.RegisterResource(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var categories = registry.GetCategories();
|
||||||
|
logger.LogInformation("MCP Resource Registry initialized: {ResourceCount} resources in {CategoryCount} categories: {Categories}",
|
||||||
|
resources.Count, categories.Count, string.Join(", ", categories));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
private IMcpResource CreateMockResource(string uri, string name)
|
||||||
{
|
{
|
||||||
var resource = Substitute.For<IMcpResource>();
|
var resource = Substitute.For<IMcpResource>();
|
||||||
resource.Uri.Returns(uri);
|
resource.UriReturns(uri);
|
||||||
resource.Name.Returns(name);
|
resource.NameReturns(name);
|
||||||
resource.Description.Returns($"Description for {name}");
|
resource.DescriptionReturns($"Description for {name}");
|
||||||
resource.MimeType.Returns("application/json");
|
resource.MimeTypeReturns("application/json");
|
||||||
resource.GetContentAsync(Arg.Any<McpResourceRequest>(), Arg.Any<CancellationToken>())
|
resource.GetContentAsync(Arg.Any<McpResourceRequest>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new McpResourceContent
|
Returns(new McpResourceContent
|
||||||
{
|
{
|
||||||
Uri = uri,
|
Uri = uri,
|
||||||
MimeType = "application/json",
|
MimeType = "application/json",
|
||||||
@@ -141,4 +141,154 @@ public class McpResourceRegistryTests
|
|||||||
});
|
});
|
||||||
return resource;
|
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