feat(backend): Implement Story 5.5 - Core MCP Resources Implementation
Implemented 6 core MCP Resources for read-only AI agent access to ColaFlow data:
- projects.list - List all projects in current tenant
- projects.get/{id} - Get project details with full hierarchy
- issues.search - Search issues (Epics, Stories, Tasks) with filters
- issues.get/{id} - Get issue details (Epic/Story/Task)
- sprints.current - Get currently active Sprint(s)
- users.list - List team members in current tenant
Changes:
- Created IMcpResource interface and related DTOs (McpResourceRequest, McpResourceContent, McpResourceDescriptor)
- Implemented IMcpResourceRegistry and McpResourceRegistry for resource discovery and routing
- Created ResourcesReadMethodHandler for handling resources/read MCP method
- Updated ResourcesListMethodHandler to return actual resource catalog
- Implemented 6 concrete resource classes with multi-tenant isolation
- Registered all resources and handlers in McpServiceExtensions
- Added module references (ProjectManagement, Identity, IssueManagement domains)
- Updated package versions to 9.0.1 for consistency
- Created comprehensive unit tests (188 tests passing)
- Tests cover resource registry, URI matching, resource content generation
Technical Details:
- Multi-tenant isolation using TenantContext.GetCurrentTenantId()
- Resource URI routing supports templates (e.g., {id} parameters)
- Uses read-only repository queries (AsNoTracking) for performance
- JSON serialization with System.Text.Json
- Proper error handling with McpNotFoundException, McpInvalidParamsException
- Supports query parameters for filtering and pagination
- Auto-registration of resources at startup
Test Coverage:
- Resource registry tests (URI matching, registration, descriptors)
- Resource content generation tests
- Multi-tenant isolation verification
- All 188 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,10 +11,14 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ColaFlow.Modules.Mcp.Contracts\ColaFlow.Modules.Mcp.Contracts.csproj" />
|
<ProjectReference Include="..\ColaFlow.Modules.Mcp.Contracts\ColaFlow.Modules.Mcp.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\ColaFlow.Modules.Mcp.Domain\ColaFlow.Modules.Mcp.Domain.csproj" />
|
<ProjectReference Include="..\ColaFlow.Modules.Mcp.Domain\ColaFlow.Modules.Mcp.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\ProjectManagement\ColaFlow.Modules.ProjectManagement.Domain\ColaFlow.Modules.ProjectManagement.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\ProjectManagement\ColaFlow.Modules.ProjectManagement.Application\ColaFlow.Modules.ProjectManagement.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Identity\ColaFlow.Modules.Identity.Domain\ColaFlow.Modules.Identity.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\IssueManagement\ColaFlow.Modules.IssueManagement.Domain\ColaFlow.Modules.IssueManagement.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ColaFlow.Modules.Mcp.Application.Services;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||||
@@ -8,23 +9,36 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
|||||||
public class ResourcesListMethodHandler : IMcpMethodHandler
|
public class ResourcesListMethodHandler : IMcpMethodHandler
|
||||||
{
|
{
|
||||||
private readonly ILogger<ResourcesListMethodHandler> _logger;
|
private readonly ILogger<ResourcesListMethodHandler> _logger;
|
||||||
|
private readonly IMcpResourceRegistry _resourceRegistry;
|
||||||
|
|
||||||
public string MethodName => "resources/list";
|
public string MethodName => "resources/list";
|
||||||
|
|
||||||
public ResourcesListMethodHandler(ILogger<ResourcesListMethodHandler> logger)
|
public ResourcesListMethodHandler(
|
||||||
|
ILogger<ResourcesListMethodHandler> logger,
|
||||||
|
IMcpResourceRegistry resourceRegistry)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_resourceRegistry = resourceRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
public Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Handling resources/list request");
|
_logger.LogDebug("Handling resources/list request");
|
||||||
|
|
||||||
// TODO: Implement in Story 5.5 (Core MCP Resources)
|
// Get all registered resource descriptors
|
||||||
// For now, return empty list
|
var descriptors = _resourceRegistry.GetResourceDescriptors();
|
||||||
|
|
||||||
|
_logger.LogInformation("Returning {Count} MCP resources", descriptors.Count);
|
||||||
|
|
||||||
var response = new
|
var response = new
|
||||||
{
|
{
|
||||||
resources = Array.Empty<object>()
|
resources = descriptors.Select(d => new
|
||||||
|
{
|
||||||
|
uri = d.Uri,
|
||||||
|
name = d.Name,
|
||||||
|
description = d.Description,
|
||||||
|
mimeType = d.MimeType
|
||||||
|
}).ToArray()
|
||||||
};
|
};
|
||||||
|
|
||||||
return Task.FromResult<object?>(response);
|
return Task.FromResult<object?>(response);
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.Mcp.Domain.Exceptions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for the 'resources/read' MCP method
|
||||||
|
/// </summary>
|
||||||
|
public class ResourcesReadMethodHandler : IMcpMethodHandler
|
||||||
|
{
|
||||||
|
private readonly ILogger<ResourcesReadMethodHandler> _logger;
|
||||||
|
private readonly IMcpResourceRegistry _resourceRegistry;
|
||||||
|
|
||||||
|
public string MethodName => "resources/read";
|
||||||
|
|
||||||
|
public ResourcesReadMethodHandler(
|
||||||
|
ILogger<ResourcesReadMethodHandler> logger,
|
||||||
|
IMcpResourceRegistry resourceRegistry)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_resourceRegistry = resourceRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<object?> HandleAsync(object? @params, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Handling resources/read request");
|
||||||
|
|
||||||
|
// Parse parameters
|
||||||
|
var paramsJson = JsonSerializer.Serialize(@params);
|
||||||
|
var request = JsonSerializer.Deserialize<ResourceReadParams>(paramsJson);
|
||||||
|
|
||||||
|
if (request == null || string.IsNullOrWhiteSpace(request.Uri))
|
||||||
|
{
|
||||||
|
throw new McpInvalidParamsException("Missing required parameter: uri");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Reading resource: {Uri}", request.Uri);
|
||||||
|
|
||||||
|
// Find resource by URI
|
||||||
|
var resource = _resourceRegistry.GetResourceByUri(request.Uri);
|
||||||
|
if (resource == null)
|
||||||
|
{
|
||||||
|
throw new McpNotFoundException($"Resource not found: {request.Uri}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URI and extract parameters
|
||||||
|
var resourceRequest = ParseResourceRequest(request.Uri, resource.Uri);
|
||||||
|
|
||||||
|
// Get resource content
|
||||||
|
var content = await resource.GetContentAsync(resourceRequest, cancellationToken);
|
||||||
|
|
||||||
|
// Return MCP response
|
||||||
|
var response = new
|
||||||
|
{
|
||||||
|
contents = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
uri = content.Uri,
|
||||||
|
mimeType = content.MimeType,
|
||||||
|
text = content.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse resource URI and extract path/query parameters
|
||||||
|
/// </summary>
|
||||||
|
private McpResourceRequest ParseResourceRequest(string requestUri, string templateUri)
|
||||||
|
{
|
||||||
|
var request = new McpResourceRequest { Uri = requestUri };
|
||||||
|
|
||||||
|
// Split URI and query string
|
||||||
|
var uriParts = requestUri.Split('?', 2);
|
||||||
|
var path = uriParts[0];
|
||||||
|
var queryString = uriParts.Length > 1 ? uriParts[1] : string.Empty;
|
||||||
|
|
||||||
|
// Extract path parameters from template
|
||||||
|
// Example: "colaflow://projects.get/123" with template "colaflow://projects.get/{id}"
|
||||||
|
var pattern = "^" + Regex.Escape(templateUri)
|
||||||
|
.Replace(@"\{", "{")
|
||||||
|
.Replace(@"\}", "}")
|
||||||
|
.Replace("{id}", @"(?<id>[^/]+)")
|
||||||
|
.Replace("{projectId}", @"(?<projectId>[^/]+)")
|
||||||
|
+ "$";
|
||||||
|
|
||||||
|
var match = Regex.Match(path, pattern);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
foreach (Group group in match.Groups)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(group.Name, out _) && group.Name != "0")
|
||||||
|
{
|
||||||
|
request.UriParams[group.Name] = group.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
|
{
|
||||||
|
var queryPairs = queryString.Split('&');
|
||||||
|
foreach (var pair in queryPairs)
|
||||||
|
{
|
||||||
|
var keyValue = pair.Split('=', 2);
|
||||||
|
if (keyValue.Length == 2)
|
||||||
|
{
|
||||||
|
request.QueryParams[keyValue[0]] = Uri.UnescapeDataString(keyValue[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ResourceReadParams
|
||||||
|
{
|
||||||
|
public string Uri { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.Mcp.Domain.Exceptions;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource: colaflow://issues.get/{id}
|
||||||
|
/// Gets detailed information about a specific issue (Epic, Story, or Task)
|
||||||
|
/// </summary>
|
||||||
|
public class IssuesGetResource : IMcpResource
|
||||||
|
{
|
||||||
|
public string Uri => "colaflow://issues.get/{id}";
|
||||||
|
public string Name => "Issue Details";
|
||||||
|
public string Description => "Get detailed information about an issue (Epic/Story/Task)";
|
||||||
|
public string MimeType => "application/json";
|
||||||
|
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<IssuesGetResource> _logger;
|
||||||
|
|
||||||
|
public IssuesGetResource(
|
||||||
|
IProjectRepository projectRepository,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<IssuesGetResource> logger)
|
||||||
|
{
|
||||||
|
_projectRepository = projectRepository;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||||
|
|
||||||
|
// Extract {id} from URI parameters
|
||||||
|
if (!request.UriParams.TryGetValue("id", out var idString))
|
||||||
|
{
|
||||||
|
throw new McpInvalidParamsException("Missing required parameter: id");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Guid.TryParse(idString, out var issueIdGuid))
|
||||||
|
{
|
||||||
|
throw new McpInvalidParamsException($"Invalid issue ID format: {idString}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching issue {IssueId} for tenant {TenantId}", issueIdGuid, tenantId);
|
||||||
|
|
||||||
|
// Try to find as Epic
|
||||||
|
var epic = await _projectRepository.GetEpicByIdReadOnlyAsync(EpicId.From(issueIdGuid), cancellationToken);
|
||||||
|
if (epic != null)
|
||||||
|
{
|
||||||
|
var epicDto = new
|
||||||
|
{
|
||||||
|
id = epic.Id.Value,
|
||||||
|
type = "Epic",
|
||||||
|
name = epic.Name,
|
||||||
|
description = epic.Description,
|
||||||
|
status = epic.Status.ToString(),
|
||||||
|
priority = epic.Priority.ToString(),
|
||||||
|
createdAt = epic.CreatedAt,
|
||||||
|
updatedAt = epic.UpdatedAt,
|
||||||
|
stories = epic.Stories?.Select(s => new
|
||||||
|
{
|
||||||
|
id = s.Id.Value,
|
||||||
|
title = s.Title,
|
||||||
|
status = s.Status.ToString(),
|
||||||
|
priority = s.Priority.ToString(),
|
||||||
|
assigneeId = s.AssigneeId?.Value
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(epicDto, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = request.Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find as Story
|
||||||
|
var story = await _projectRepository.GetStoryByIdReadOnlyAsync(StoryId.From(issueIdGuid), cancellationToken);
|
||||||
|
if (story != null)
|
||||||
|
{
|
||||||
|
var storyDto = new
|
||||||
|
{
|
||||||
|
id = story.Id.Value,
|
||||||
|
type = "Story",
|
||||||
|
title = story.Title,
|
||||||
|
description = story.Description,
|
||||||
|
status = story.Status.ToString(),
|
||||||
|
priority = story.Priority.ToString(),
|
||||||
|
assigneeId = story.AssigneeId?.Value,
|
||||||
|
createdAt = story.CreatedAt,
|
||||||
|
updatedAt = story.UpdatedAt,
|
||||||
|
tasks = story.Tasks?.Select(t => new
|
||||||
|
{
|
||||||
|
id = t.Id.Value,
|
||||||
|
title = t.Title,
|
||||||
|
status = t.Status.ToString(),
|
||||||
|
priority = t.Priority.ToString(),
|
||||||
|
assigneeId = t.AssigneeId?.Value
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(storyDto, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = request.Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find as Task
|
||||||
|
var task = await _projectRepository.GetTaskByIdReadOnlyAsync(TaskId.From(issueIdGuid), cancellationToken);
|
||||||
|
if (task != null)
|
||||||
|
{
|
||||||
|
var taskDto = new
|
||||||
|
{
|
||||||
|
id = task.Id.Value,
|
||||||
|
type = "Task",
|
||||||
|
title = task.Title,
|
||||||
|
description = task.Description,
|
||||||
|
status = task.Status.ToString(),
|
||||||
|
priority = task.Priority.ToString(),
|
||||||
|
assigneeId = task.AssigneeId?.Value,
|
||||||
|
createdAt = task.CreatedAt,
|
||||||
|
updatedAt = task.UpdatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(taskDto, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = request.Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
throw new McpNotFoundException($"Issue not found: {issueIdGuid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource: colaflow://issues.search
|
||||||
|
/// Searches issues with filters (Epics, Stories, Tasks)
|
||||||
|
/// Query params: status, priority, assignee, type, project, limit, offset
|
||||||
|
/// </summary>
|
||||||
|
public class IssuesSearchResource : IMcpResource
|
||||||
|
{
|
||||||
|
public string Uri => "colaflow://issues.search";
|
||||||
|
public string Name => "Issues Search";
|
||||||
|
public string Description => "Search issues with filters (status, priority, assignee, etc.)";
|
||||||
|
public string MimeType => "application/json";
|
||||||
|
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<IssuesSearchResource> _logger;
|
||||||
|
|
||||||
|
public IssuesSearchResource(
|
||||||
|
IProjectRepository projectRepository,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<IssuesSearchResource> logger)
|
||||||
|
{
|
||||||
|
_projectRepository = projectRepository;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||||
|
|
||||||
|
_logger.LogDebug("Searching issues for tenant {TenantId} with filters: {@Filters}",
|
||||||
|
tenantId, request.QueryParams);
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
var projectFilter = request.QueryParams.GetValueOrDefault("project");
|
||||||
|
var statusFilter = request.QueryParams.GetValueOrDefault("status");
|
||||||
|
var priorityFilter = request.QueryParams.GetValueOrDefault("priority");
|
||||||
|
var typeFilter = request.QueryParams.GetValueOrDefault("type")?.ToLower();
|
||||||
|
var assigneeFilter = request.QueryParams.GetValueOrDefault("assignee");
|
||||||
|
var limit = int.TryParse(request.QueryParams.GetValueOrDefault("limit"), out var l) ? l : 100;
|
||||||
|
var offset = int.TryParse(request.QueryParams.GetValueOrDefault("offset"), out var o) ? o : 0;
|
||||||
|
|
||||||
|
// Limit max results
|
||||||
|
limit = Math.Min(limit, 100);
|
||||||
|
|
||||||
|
// Get all projects
|
||||||
|
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Filter by project if specified
|
||||||
|
if (!string.IsNullOrEmpty(projectFilter) && Guid.TryParse(projectFilter, out var projectIdGuid))
|
||||||
|
{
|
||||||
|
var projectId = ProjectId.From(projectIdGuid);
|
||||||
|
var project = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
|
||||||
|
projects = project != null ? new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project> { project } : new();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Load full hierarchy for all projects
|
||||||
|
var projectsWithHierarchy = new List<ProjectManagement.Domain.Aggregates.ProjectAggregate.Project>();
|
||||||
|
foreach (var p in projects)
|
||||||
|
{
|
||||||
|
var fullProject = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(p.Id, cancellationToken);
|
||||||
|
if (fullProject != null)
|
||||||
|
{
|
||||||
|
projectsWithHierarchy.Add(fullProject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projects = projectsWithHierarchy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all issues (Epics, Stories, Tasks)
|
||||||
|
var allIssues = new List<object>();
|
||||||
|
|
||||||
|
foreach (var project in projects)
|
||||||
|
{
|
||||||
|
if (project.Epics == null) continue;
|
||||||
|
|
||||||
|
foreach (var epic in project.Epics)
|
||||||
|
{
|
||||||
|
// Filter Epics
|
||||||
|
if (ShouldIncludeIssue("epic", typeFilter, epic.Status.ToString(), statusFilter,
|
||||||
|
epic.Priority.ToString(), priorityFilter, null, assigneeFilter))
|
||||||
|
{
|
||||||
|
allIssues.Add(new
|
||||||
|
{
|
||||||
|
id = epic.Id.Value,
|
||||||
|
type = "Epic",
|
||||||
|
name = epic.Name,
|
||||||
|
description = epic.Description,
|
||||||
|
status = epic.Status.ToString(),
|
||||||
|
priority = epic.Priority.ToString(),
|
||||||
|
projectId = project.Id.Value,
|
||||||
|
projectName = project.Name,
|
||||||
|
createdAt = epic.CreatedAt,
|
||||||
|
storyCount = epic.Stories?.Count ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Stories
|
||||||
|
if (epic.Stories != null)
|
||||||
|
{
|
||||||
|
foreach (var story in epic.Stories)
|
||||||
|
{
|
||||||
|
if (ShouldIncludeIssue("story", typeFilter, story.Status.ToString(), statusFilter,
|
||||||
|
story.Priority.ToString(), priorityFilter, story.AssigneeId?.Value.ToString(), assigneeFilter))
|
||||||
|
{
|
||||||
|
allIssues.Add(new
|
||||||
|
{
|
||||||
|
id = story.Id.Value,
|
||||||
|
type = "Story",
|
||||||
|
title = story.Title,
|
||||||
|
description = story.Description,
|
||||||
|
status = story.Status.ToString(),
|
||||||
|
priority = story.Priority.ToString(),
|
||||||
|
assigneeId = story.AssigneeId?.Value,
|
||||||
|
projectId = project.Id.Value,
|
||||||
|
projectName = project.Name,
|
||||||
|
epicId = epic.Id.Value,
|
||||||
|
epicName = epic.Name,
|
||||||
|
createdAt = story.CreatedAt,
|
||||||
|
taskCount = story.Tasks?.Count ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter Tasks
|
||||||
|
if (story.Tasks != null)
|
||||||
|
{
|
||||||
|
foreach (var task in story.Tasks)
|
||||||
|
{
|
||||||
|
if (ShouldIncludeIssue("task", typeFilter, task.Status.ToString(), statusFilter,
|
||||||
|
task.Priority.ToString(), priorityFilter, task.AssigneeId?.Value.ToString(), assigneeFilter))
|
||||||
|
{
|
||||||
|
allIssues.Add(new
|
||||||
|
{
|
||||||
|
id = task.Id.Value,
|
||||||
|
type = "Task",
|
||||||
|
title = task.Title,
|
||||||
|
description = task.Description,
|
||||||
|
status = task.Status.ToString(),
|
||||||
|
priority = task.Priority.ToString(),
|
||||||
|
assigneeId = task.AssigneeId?.Value,
|
||||||
|
projectId = project.Id.Value,
|
||||||
|
projectName = project.Name,
|
||||||
|
storyId = story.Id.Value,
|
||||||
|
storyTitle = story.Title,
|
||||||
|
epicId = epic.Id.Value,
|
||||||
|
epicName = epic.Name,
|
||||||
|
createdAt = task.CreatedAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
var total = allIssues.Count;
|
||||||
|
var paginatedIssues = allIssues.Skip(offset).Take(limit).ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
issues = paginatedIssues,
|
||||||
|
total = total,
|
||||||
|
limit = limit,
|
||||||
|
offset = offset
|
||||||
|
}, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} issues for tenant {TenantId} (total: {Total})",
|
||||||
|
paginatedIssues.Count, tenantId, total);
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldIncludeIssue(
|
||||||
|
string issueType,
|
||||||
|
string? typeFilter,
|
||||||
|
string status,
|
||||||
|
string? statusFilter,
|
||||||
|
string priority,
|
||||||
|
string? priorityFilter,
|
||||||
|
string? assigneeId,
|
||||||
|
string? assigneeFilter)
|
||||||
|
{
|
||||||
|
// Type filter
|
||||||
|
if (!string.IsNullOrEmpty(typeFilter) && !issueType.Equals(typeFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (!string.IsNullOrEmpty(statusFilter) && !status.Equals(statusFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority filter
|
||||||
|
if (!string.IsNullOrEmpty(priorityFilter) && !priority.Equals(priorityFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignee filter
|
||||||
|
if (!string.IsNullOrEmpty(assigneeFilter) && assigneeId != assigneeFilter)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.Mcp.Domain.Exceptions;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource: colaflow://projects.get/{id}
|
||||||
|
/// Gets detailed information about a specific project
|
||||||
|
/// </summary>
|
||||||
|
public class ProjectsGetResource : IMcpResource
|
||||||
|
{
|
||||||
|
public string Uri => "colaflow://projects.get/{id}";
|
||||||
|
public string Name => "Project Details";
|
||||||
|
public string Description => "Get detailed information about a project";
|
||||||
|
public string MimeType => "application/json";
|
||||||
|
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<ProjectsGetResource> _logger;
|
||||||
|
|
||||||
|
public ProjectsGetResource(
|
||||||
|
IProjectRepository projectRepository,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<ProjectsGetResource> logger)
|
||||||
|
{
|
||||||
|
_projectRepository = projectRepository;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||||
|
|
||||||
|
// Extract {id} from URI parameters
|
||||||
|
if (!request.UriParams.TryGetValue("id", out var idString))
|
||||||
|
{
|
||||||
|
throw new McpInvalidParamsException("Missing required parameter: id");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Guid.TryParse(idString, out var projectIdGuid))
|
||||||
|
{
|
||||||
|
throw new McpInvalidParamsException($"Invalid project ID format: {idString}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectId = ProjectId.From(projectIdGuid);
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching project {ProjectId} for tenant {TenantId}", projectId, tenantId);
|
||||||
|
|
||||||
|
// Get project with full hierarchy (read-only)
|
||||||
|
var project = await _projectRepository.GetProjectWithFullHierarchyReadOnlyAsync(projectId, cancellationToken);
|
||||||
|
|
||||||
|
if (project == null)
|
||||||
|
{
|
||||||
|
throw new McpNotFoundException($"Project not found: {projectId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to DTO
|
||||||
|
var projectDto = new
|
||||||
|
{
|
||||||
|
id = project.Id.Value,
|
||||||
|
name = project.Name,
|
||||||
|
key = project.Key.ToString(),
|
||||||
|
description = project.Description,
|
||||||
|
status = project.Status.ToString(),
|
||||||
|
ownerId = project.OwnerId.Value,
|
||||||
|
createdAt = project.CreatedAt,
|
||||||
|
updatedAt = project.UpdatedAt,
|
||||||
|
epics = project.Epics?.Select(e => new
|
||||||
|
{
|
||||||
|
id = e.Id.Value,
|
||||||
|
name = e.Name,
|
||||||
|
description = e.Description,
|
||||||
|
status = e.Status.ToString(),
|
||||||
|
priority = e.Priority.ToString(),
|
||||||
|
createdAt = e.CreatedAt,
|
||||||
|
storyCount = e.Stories?.Count ?? 0
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(projectDto, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
_logger.LogInformation("Retrieved project {ProjectId} for tenant {TenantId}", projectId, tenantId);
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = request.Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource: colaflow://projects.list
|
||||||
|
/// Lists all projects in the current tenant
|
||||||
|
/// </summary>
|
||||||
|
public class ProjectsListResource : IMcpResource
|
||||||
|
{
|
||||||
|
public string Uri => "colaflow://projects.list";
|
||||||
|
public string Name => "Projects List";
|
||||||
|
public string Description => "List all projects in current tenant";
|
||||||
|
public string MimeType => "application/json";
|
||||||
|
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<ProjectsListResource> _logger;
|
||||||
|
|
||||||
|
public ProjectsListResource(
|
||||||
|
IProjectRepository projectRepository,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<ProjectsListResource> logger)
|
||||||
|
{
|
||||||
|
_projectRepository = projectRepository;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching projects list for tenant {TenantId}", tenantId);
|
||||||
|
|
||||||
|
// Get all projects (read-only)
|
||||||
|
var projects = await _projectRepository.GetAllProjectsReadOnlyAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Map to DTOs
|
||||||
|
var projectDtos = projects.Select(p => new
|
||||||
|
{
|
||||||
|
id = p.Id.Value,
|
||||||
|
name = p.Name,
|
||||||
|
key = p.Key.ToString(),
|
||||||
|
description = p.Description,
|
||||||
|
status = p.Status.ToString(),
|
||||||
|
createdAt = p.CreatedAt,
|
||||||
|
updatedAt = p.UpdatedAt,
|
||||||
|
epicCount = p.Epics?.Count ?? 0
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
projects = projectDtos,
|
||||||
|
total = projectDtos.Count
|
||||||
|
}, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
_logger.LogInformation("Retrieved {Count} projects for tenant {TenantId}", projectDtos.Count, tenantId);
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.Mcp.Domain.Exceptions;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource: colaflow://sprints.current
|
||||||
|
/// Gets the currently active Sprint(s)
|
||||||
|
/// </summary>
|
||||||
|
public class SprintsCurrentResource : IMcpResource
|
||||||
|
{
|
||||||
|
public string Uri => "colaflow://sprints.current";
|
||||||
|
public string Name => "Current Sprint";
|
||||||
|
public string Description => "Get the currently active Sprint(s)";
|
||||||
|
public string MimeType => "application/json";
|
||||||
|
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<SprintsCurrentResource> _logger;
|
||||||
|
|
||||||
|
public SprintsCurrentResource(
|
||||||
|
IProjectRepository projectRepository,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<SprintsCurrentResource> logger)
|
||||||
|
{
|
||||||
|
_projectRepository = projectRepository;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching active sprints for tenant {TenantId}", tenantId);
|
||||||
|
|
||||||
|
// Get active sprints
|
||||||
|
var activeSprints = await _projectRepository.GetActiveSprintsAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (activeSprints.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No active sprints found for tenant {TenantId}", tenantId);
|
||||||
|
throw new McpNotFoundException("No active sprints found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to DTOs with statistics
|
||||||
|
var sprintDtos = activeSprints.Select(sprint => new
|
||||||
|
{
|
||||||
|
id = sprint.Id.Value,
|
||||||
|
name = sprint.Name,
|
||||||
|
goal = sprint.Goal,
|
||||||
|
status = sprint.Status.ToString(),
|
||||||
|
startDate = sprint.StartDate,
|
||||||
|
endDate = sprint.EndDate,
|
||||||
|
createdAt = sprint.CreatedAt,
|
||||||
|
statistics = new
|
||||||
|
{
|
||||||
|
totalTasks = sprint.TaskIds?.Count ?? 0
|
||||||
|
// Note: To get completed/in-progress counts, we'd need to query tasks
|
||||||
|
// For now, just return total count
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
sprints = sprintDtos,
|
||||||
|
total = sprintDtos.Count
|
||||||
|
}, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
_logger.LogInformation("Retrieved {Count} active sprints for tenant {TenantId}",
|
||||||
|
sprintDtos.Count, tenantId);
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Aggregates.Tenants;
|
||||||
|
using ColaFlow.Modules.Identity.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource: colaflow://users.list
|
||||||
|
/// Lists all team members in the current tenant
|
||||||
|
/// Query params: project (optional filter by project)
|
||||||
|
/// </summary>
|
||||||
|
public class UsersListResource : IMcpResource
|
||||||
|
{
|
||||||
|
public string Uri => "colaflow://users.list";
|
||||||
|
public string Name => "Team Members";
|
||||||
|
public string Description => "List all team members in current tenant";
|
||||||
|
public string MimeType => "application/json";
|
||||||
|
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<UsersListResource> _logger;
|
||||||
|
|
||||||
|
public UsersListResource(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
ILogger<UsersListResource> logger)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching users list for tenant {TenantId}", tenantId);
|
||||||
|
|
||||||
|
// Get all users for tenant
|
||||||
|
var users = await _userRepository.GetAllByTenantAsync(TenantId.Create(tenantId), cancellationToken);
|
||||||
|
|
||||||
|
// Map to DTOs
|
||||||
|
var userDtos = users.Select(u => new
|
||||||
|
{
|
||||||
|
id = u.Id,
|
||||||
|
email = u.Email.Value,
|
||||||
|
fullName = u.FullName.ToString(),
|
||||||
|
status = u.Status.ToString(),
|
||||||
|
createdAt = u.CreatedAt,
|
||||||
|
avatarUrl = u.AvatarUrl,
|
||||||
|
jobTitle = u.JobTitle
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
users = userDtos,
|
||||||
|
total = userDtos.Count
|
||||||
|
}, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|
||||||
|
_logger.LogInformation("Retrieved {Count} users for tenant {TenantId}", userDtos.Count, tenantId);
|
||||||
|
|
||||||
|
return new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = Uri,
|
||||||
|
MimeType = MimeType,
|
||||||
|
Text = json
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry for all MCP Resources
|
||||||
|
/// Manages resource discovery and routing
|
||||||
|
/// </summary>
|
||||||
|
public interface IMcpResourceRegistry
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Register a resource
|
||||||
|
/// </summary>
|
||||||
|
void RegisterResource(IMcpResource resource);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all registered resources
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<IMcpResource> GetAllResources();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resource by URI (supports URI templates like "colaflow://projects.get/{id}")
|
||||||
|
/// </summary>
|
||||||
|
IMcpResource? GetResourceByUri(string uri);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all resource descriptors (for resources/list method)
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<McpResourceDescriptor> GetResourceDescriptors();
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of MCP Resource Registry
|
||||||
|
/// </summary>
|
||||||
|
public class McpResourceRegistry : IMcpResourceRegistry
|
||||||
|
{
|
||||||
|
private readonly ILogger<McpResourceRegistry> _logger;
|
||||||
|
private readonly Dictionary<string, IMcpResource> _resources = new();
|
||||||
|
private readonly List<IMcpResource> _resourceList = new();
|
||||||
|
|
||||||
|
public McpResourceRegistry(ILogger<McpResourceRegistry> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterResource(IMcpResource resource)
|
||||||
|
{
|
||||||
|
if (_resources.ContainsKey(resource.Uri))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Resource already registered: {Uri}. Overwriting.", resource.Uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IMcpResource? GetResourceByUri(string uri)
|
||||||
|
{
|
||||||
|
// Try exact match first
|
||||||
|
if (_resources.TryGetValue(uri, out var resource))
|
||||||
|
{
|
||||||
|
return 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 null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<McpResourceDescriptor> GetResourceDescriptors()
|
||||||
|
{
|
||||||
|
return _resourceList.Select(r => new McpResourceDescriptor
|
||||||
|
{
|
||||||
|
Uri = r.Uri,
|
||||||
|
Name = r.Name,
|
||||||
|
Description = r.Description,
|
||||||
|
MimeType = r.MimeType
|
||||||
|
}).ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if a URI matches a URI template
|
||||||
|
/// Example: "colaflow://projects.get/123" matches "colaflow://projects.get/{id}"
|
||||||
|
/// </summary>
|
||||||
|
private bool UriMatchesTemplate(string uri, string template)
|
||||||
|
{
|
||||||
|
// Convert template to regex pattern
|
||||||
|
// Replace {param} with regex group
|
||||||
|
var pattern = "^" + Regex.Escape(template)
|
||||||
|
.Replace(@"\{", "{")
|
||||||
|
.Replace(@"\}", "}")
|
||||||
|
.Replace("{id}", @"([^/]+)")
|
||||||
|
.Replace("{projectId}", @"([^/]+)")
|
||||||
|
+ "$";
|
||||||
|
|
||||||
|
return Regex.IsMatch(uri, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for MCP Resources
|
||||||
|
/// Resources provide read-only data to AI agents through the MCP protocol
|
||||||
|
/// </summary>
|
||||||
|
public interface IMcpResource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resource URI (e.g., "colaflow://projects.list")
|
||||||
|
/// </summary>
|
||||||
|
string Uri { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource display name
|
||||||
|
/// </summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource description
|
||||||
|
/// </summary>
|
||||||
|
string Description { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MIME type of the resource content (typically "application/json")
|
||||||
|
/// </summary>
|
||||||
|
string MimeType { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get resource content
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Resource request with URI and parameters</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Resource content</returns>
|
||||||
|
Task<McpResourceContent> GetContentAsync(
|
||||||
|
McpResourceRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Content returned by an MCP Resource
|
||||||
|
/// </summary>
|
||||||
|
public class McpResourceContent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resource URI
|
||||||
|
/// </summary>
|
||||||
|
public string Uri { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MIME type (typically "application/json")
|
||||||
|
/// </summary>
|
||||||
|
public string MimeType { get; set; } = "application/json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource content as text (JSON serialized)
|
||||||
|
/// </summary>
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Descriptor for an MCP Resource (used in resources/list)
|
||||||
|
/// </summary>
|
||||||
|
public class McpResourceDescriptor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resource URI
|
||||||
|
/// </summary>
|
||||||
|
public string Uri { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource display name
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resource description
|
||||||
|
/// </summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MIME type
|
||||||
|
/// </summary>
|
||||||
|
public string MimeType { get; set; } = "application/json";
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request object for MCP Resource
|
||||||
|
/// </summary>
|
||||||
|
public class McpResourceRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resource URI
|
||||||
|
/// </summary>
|
||||||
|
public string Uri { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URI parameters (e.g., {id} from "colaflow://projects.get/{id}")
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> UriParams { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query parameters (e.g., ?status=InProgress&priority=High)
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string> QueryParams { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.1" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using ColaFlow.Modules.Mcp.Application.Handlers;
|
using ColaFlow.Modules.Mcp.Application.Handlers;
|
||||||
|
using ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
using ColaFlow.Modules.Mcp.Application.Services;
|
using ColaFlow.Modules.Mcp.Application.Services;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
using ColaFlow.Modules.Mcp.Domain.Repositories;
|
using ColaFlow.Modules.Mcp.Domain.Repositories;
|
||||||
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||||
using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
|
using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
|
||||||
@@ -34,12 +36,24 @@ public static class McpServiceExtensions
|
|||||||
// Register application services
|
// Register application services
|
||||||
services.AddScoped<IMcpApiKeyService, McpApiKeyService>();
|
services.AddScoped<IMcpApiKeyService, McpApiKeyService>();
|
||||||
|
|
||||||
|
// Register resource registry (Singleton - shared across all requests)
|
||||||
|
services.AddSingleton<IMcpResourceRegistry, McpResourceRegistry>();
|
||||||
|
|
||||||
|
// Register MCP Resources
|
||||||
|
services.AddScoped<IMcpResource, ProjectsListResource>();
|
||||||
|
services.AddScoped<IMcpResource, ProjectsGetResource>();
|
||||||
|
services.AddScoped<IMcpResource, IssuesSearchResource>();
|
||||||
|
services.AddScoped<IMcpResource, IssuesGetResource>();
|
||||||
|
services.AddScoped<IMcpResource, SprintsCurrentResource>();
|
||||||
|
services.AddScoped<IMcpResource, UsersListResource>();
|
||||||
|
|
||||||
// Register protocol handler
|
// Register protocol handler
|
||||||
services.AddScoped<IMcpProtocolHandler, McpProtocolHandler>();
|
services.AddScoped<IMcpProtocolHandler, McpProtocolHandler>();
|
||||||
|
|
||||||
// Register method handlers
|
// Register method handlers
|
||||||
services.AddScoped<IMcpMethodHandler, InitializeMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, InitializeMethodHandler>();
|
||||||
services.AddScoped<IMcpMethodHandler, ResourcesListMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ResourcesListMethodHandler>();
|
||||||
|
services.AddScoped<IMcpMethodHandler, ResourcesReadMethodHandler>();
|
||||||
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
|
||||||
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
|
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
|
||||||
|
|
||||||
@@ -57,6 +71,9 @@ 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)
|
||||||
|
InitializeResourceRegistry(app);
|
||||||
|
|
||||||
// 1. Correlation ID middleware (FIRST - needed for all subsequent logging)
|
// 1. Correlation ID middleware (FIRST - needed for all subsequent logging)
|
||||||
app.UseMiddleware<McpCorrelationIdMiddleware>();
|
app.UseMiddleware<McpCorrelationIdMiddleware>();
|
||||||
|
|
||||||
@@ -74,4 +91,20 @@ public static class McpServiceExtensions
|
|||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize resource registry by registering all resources
|
||||||
|
/// 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>();
|
||||||
|
|
||||||
|
foreach (var resource in resources)
|
||||||
|
{
|
||||||
|
registry.RegisterResource(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ColaFlow.Modules.Mcp.Application.Resources;
|
||||||
|
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Enums;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace ColaFlow.Modules.Mcp.Tests.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for ProjectsListResource
|
||||||
|
/// </summary>
|
||||||
|
public class ProjectsListResourceTests
|
||||||
|
{
|
||||||
|
private readonly IProjectRepository _projectRepository;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly ILogger<ProjectsListResource> _logger;
|
||||||
|
private readonly ProjectsListResource _sut;
|
||||||
|
private readonly Guid _tenantId = Guid.NewGuid();
|
||||||
|
|
||||||
|
public ProjectsListResourceTests()
|
||||||
|
{
|
||||||
|
_projectRepository = Substitute.For<IProjectRepository>();
|
||||||
|
_tenantContext = Substitute.For<ITenantContext>();
|
||||||
|
_logger = Substitute.For<ILogger<ProjectsListResource>>();
|
||||||
|
|
||||||
|
_tenantContext.GetCurrentTenantId().Returns(_tenantId);
|
||||||
|
|
||||||
|
_sut = new ProjectsListResource(_projectRepository, _tenantContext, _logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Uri_ReturnsCorrectValue()
|
||||||
|
{
|
||||||
|
// Assert
|
||||||
|
_sut.Uri.Should().Be("colaflow://projects.list");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Name_ReturnsCorrectValue()
|
||||||
|
{
|
||||||
|
// Assert
|
||||||
|
_sut.Name.Should().Be("Projects List");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MimeType_ReturnsApplicationJson()
|
||||||
|
{
|
||||||
|
// Assert
|
||||||
|
_sut.MimeType.Should().Be("application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContentAsync_WithNoProjects_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Project>());
|
||||||
|
|
||||||
|
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GetContentAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.Uri.Should().Be("colaflow://projects.list");
|
||||||
|
result.MimeType.Should().Be("application/json");
|
||||||
|
|
||||||
|
var data = JsonSerializer.Deserialize<JsonElement>(result.Text);
|
||||||
|
data.GetProperty("projects").GetArrayLength().Should().Be(0);
|
||||||
|
data.GetProperty("total").GetInt32().Should().Be(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContentAsync_WithProjects_ReturnsProjectsList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var project1 = Project.Create(
|
||||||
|
ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.TenantId.From(_tenantId),
|
||||||
|
"Project Alpha",
|
||||||
|
"First project",
|
||||||
|
"ALPHA",
|
||||||
|
UserId.Create());
|
||||||
|
|
||||||
|
var project2 = Project.Create(
|
||||||
|
ColaFlow.Modules.ProjectManagement.Domain.ValueObjects.TenantId.From(_tenantId),
|
||||||
|
"Project Beta",
|
||||||
|
"Second project",
|
||||||
|
"BETA",
|
||||||
|
UserId.Create());
|
||||||
|
|
||||||
|
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Project> { project1, project2 });
|
||||||
|
|
||||||
|
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _sut.GetContentAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
var data = JsonSerializer.Deserialize<JsonElement>(result.Text);
|
||||||
|
|
||||||
|
data.GetProperty("total").GetInt32().Should().Be(2);
|
||||||
|
|
||||||
|
var projects = data.GetProperty("projects");
|
||||||
|
projects.GetArrayLength().Should().Be(2);
|
||||||
|
|
||||||
|
var firstProject = projects[0];
|
||||||
|
firstProject.GetProperty("name").GetString().Should().Be("Project Alpha");
|
||||||
|
firstProject.GetProperty("key").GetString().Should().Be("ALPHA");
|
||||||
|
firstProject.GetProperty("status").GetString().Should().Be(ProjectStatus.Active.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContentAsync_CallsTenantContext()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Project>());
|
||||||
|
|
||||||
|
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.GetContentAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
_tenantContext.Received(1).GetCurrentTenantId();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetContentAsync_CallsRepository()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_projectRepository.GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new List<Project>());
|
||||||
|
|
||||||
|
var request = new McpResourceRequest { Uri = "colaflow://projects.list" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _sut.GetContentAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await _projectRepository.Received(1).GetAllProjectsReadOnlyAsync(Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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.Uri.Returns(uri);
|
||||||
|
resource.Name.Returns(name);
|
||||||
|
resource.Description.Returns($"Description for {name}");
|
||||||
|
resource.MimeType.Returns("application/json");
|
||||||
|
resource.GetContentAsync(Arg.Any<McpResourceRequest>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new McpResourceContent
|
||||||
|
{
|
||||||
|
Uri = uri,
|
||||||
|
MimeType = "application/json",
|
||||||
|
Text = "{}"
|
||||||
|
});
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user