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,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);
}
}

View File

@@ -5,6 +5,7 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
/// <summary>
/// Handler for the 'resources/list' MCP method
/// Returns categorized list of all available resources with full metadata
/// </summary>
public class ResourcesListMethodHandler : IMcpMethodHandler
{
@@ -25,10 +26,32 @@ public class ResourcesListMethodHandler : IMcpMethodHandler
{
_logger.LogDebug("Handling resources/list request");
// Get all registered resource descriptors
// Get all registered resource descriptors with full metadata
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
{
@@ -37,8 +60,18 @@ public class ResourcesListMethodHandler : IMcpMethodHandler
uri = d.Uri,
name = d.Name,
description = d.Description,
mimeType = d.MimeType
}).ToArray()
mimeType = d.MimeType,
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);

View File

@@ -18,6 +18,8 @@ public class IssuesGetResource : IMcpResource
public string Name => "Issue Details";
public string Description => "Get detailed information about an issue (Epic/Story/Task)";
public string MimeType => "application/json";
public string Category => "Issues";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;

View File

@@ -18,6 +18,8 @@ public class IssuesSearchResource : IMcpResource
public string Name => "Issues Search";
public string Description => "Search issues with filters (status, priority, assignee, etc.)";
public string MimeType => "application/json";
public string Category => "Issues";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;

View File

@@ -18,6 +18,8 @@ public class ProjectsGetResource : IMcpResource
public string Name => "Project Details";
public string Description => "Get detailed information about a project";
public string MimeType => "application/json";
public string Category => "Projects";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
@@ -33,6 +35,30 @@ public class ProjectsGetResource : IMcpResource
_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(
McpResourceRequest request,
CancellationToken cancellationToken)

View File

@@ -16,6 +16,8 @@ public class ProjectsListResource : IMcpResource
public string Name => "Projects List";
public string Description => "List all projects in current tenant";
public string MimeType => "application/json";
public string Category => "Projects";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;
@@ -31,6 +33,27 @@ public class ProjectsListResource : IMcpResource
_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(
McpResourceRequest request,
CancellationToken cancellationToken)

View File

@@ -17,6 +17,8 @@ public class SprintsCurrentResource : IMcpResource
public string Name => "Current Sprint";
public string Description => "Get the currently active Sprint(s)";
public string MimeType => "application/json";
public string Category => "Sprints";
public string Version => "1.0";
private readonly IProjectRepository _projectRepository;
private readonly ITenantContext _tenantContext;

View File

@@ -18,6 +18,8 @@ public class UsersListResource : IMcpResource
public string Name => "Team Members";
public string Description => "List all team members in current tenant";
public string MimeType => "application/json";
public string Category => "Users";
public string Version => "1.0";
private readonly IUserRepository _userRepository;
private readonly ITenantContext _tenantContext;

View File

@@ -4,7 +4,7 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
/// <summary>
/// Registry for all MCP Resources
/// Manages resource discovery and routing
/// Manages resource discovery, routing, and categorization
/// </summary>
public interface IMcpResourceRegistry
{
@@ -27,4 +27,19 @@ public interface IMcpResourceRegistry
/// Get all resource descriptors (for resources/list method)
/// </summary>
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();
}

View File

@@ -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);
}

View File

@@ -6,12 +6,14 @@ namespace ColaFlow.Modules.Mcp.Application.Services;
/// <summary>
/// Implementation of MCP Resource Registry
/// Enhanced with category support and dynamic registration
/// </summary>
public class McpResourceRegistry : IMcpResourceRegistry
{
private readonly ILogger<McpResourceRegistry> _logger;
private readonly Dictionary<string, IMcpResource> _resources = new();
private readonly List<IMcpResource> _resourceList = new();
private readonly object _lock = new();
public McpResourceRegistry(ILogger<McpResourceRegistry> logger)
{
@@ -20,51 +22,106 @@ public class McpResourceRegistry : IMcpResourceRegistry
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()
{
return _resourceList.AsReadOnly();
lock (_lock)
{
return _resourceList.AsReadOnly();
}
}
public IMcpResource? GetResourceByUri(string uri)
{
// Try exact match first
if (_resources.TryGetValue(uri, out var resource))
lock (_lock)
{
return resource;
}
// Try matching against URI templates (e.g., "colaflow://projects.get/{id}")
foreach (var registeredResource in _resourceList)
{
if (UriMatchesTemplate(uri, registeredResource.Uri))
// Try exact match first
if (_resources.TryGetValue(uri, out var resource))
{
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()
{
return _resourceList.Select(r => new McpResourceDescriptor
lock (_lock)
{
Uri = r.Uri,
Name = r.Name,
Description = r.Description,
MimeType = r.MimeType
}).ToList().AsReadOnly();
return _resourceList
.Select(r => r.GetDescriptor())
.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>

View File

@@ -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();
}
}

View File

@@ -26,6 +26,35 @@ public interface IMcpResource
/// </summary>
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>
/// Get resource content
/// </summary>

View File

@@ -2,11 +2,12 @@ namespace ColaFlow.Modules.Mcp.Contracts.Resources;
/// <summary>
/// Descriptor for an MCP Resource (used in resources/list)
/// Enhanced with metadata for better discovery and documentation
/// </summary>
public class McpResourceDescriptor
{
/// <summary>
/// Resource URI
/// Resource URI (e.g., "colaflow://projects.list")
/// </summary>
public string Uri { get; set; } = string.Empty;
@@ -21,7 +22,38 @@ public class McpResourceDescriptor
public string Description { get; set; } = string.Empty;
/// <summary>
/// MIME type
/// MIME type (default: "application/json")
/// </summary>
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;
}

View File

@@ -10,16 +10,18 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Infrastructure.Extensions;
/// <summary>
/// Extension methods for registering MCP services
/// Enhanced with auto-discovery and dynamic registration
/// </summary>
public static class McpServiceExtensions
{
/// <summary>
/// Registers MCP module services
/// Registers MCP module services with auto-discovery
/// </summary>
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)
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, ProjectsGetResource>();
services.AddScoped<IMcpResource, IssuesSearchResource>();
@@ -54,6 +59,7 @@ public static class McpServiceExtensions
services.AddScoped<IMcpMethodHandler, InitializeMethodHandler>();
services.AddScoped<IMcpMethodHandler, ResourcesListMethodHandler>();
services.AddScoped<IMcpMethodHandler, ResourcesReadMethodHandler>();
services.AddScoped<IMcpMethodHandler, ResourceHealthCheckHandler>(); // NEW: Health check handler
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
@@ -71,7 +77,7 @@ public static class McpServiceExtensions
/// </summary>
public static IApplicationBuilder UseMcpMiddleware(this IApplicationBuilder app)
{
// Initialize resource registry (register all resources)
// Initialize resource registry (register all resources with auto-discovery)
InitializeResourceRegistry(app);
// 1. Correlation ID middleware (FIRST - needed for all subsequent logging)
@@ -94,17 +100,30 @@ public static class McpServiceExtensions
/// <summary>
/// Initialize resource registry by registering all resources
/// Enhanced with auto-discovery using Assembly scanning
/// This is called once at startup
/// </summary>
private static void InitializeResourceRegistry(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
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)
{
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));
}
}