diff --git a/Architecture.md b/Architecture.md index 56decaa..d36fcd0 100644 --- a/Architecture.md +++ b/Architecture.md @@ -16,6 +16,8 @@ The PFM platform is constructed using **Clean Architecture** and **Domain-Driven │ PostFundManagement.ConsoleUI │ PostFundManagement.PartnerUI │ │ (Internal Staff & Governance) │ (External Recipient Portal) │ │ React 19 + TypeScript + Vite │ React 19 + WebGL (OGL) + Vite │ +│ │ │ +│ PostFundManagement.Mcp (External MCP Server Host - HTTP/SSE & Stdio) │ └───────────────────────────────────┬────────────────────────────────────┘ │ HTTPS / REST (v1/api/*) + OIDC PKCE ┌───────────────────────────────────▼────────────────────────────────────┐ @@ -316,3 +318,24 @@ The background job runner powers four essential operational workloads: + + +--- + +## 11. Model Context Protocol (MCP) Architectural Specification + +### 11.1 Architectural Shift & Philosophy +The PFM platform integrates the **Model Context Protocol (MCP)** standard (v2.3.0) to expose domain capabilities both internally and externally while preventing token exhaustion and eliminating network call overhead: + +1. **Dual Realisation Pattern**: + * **In-App MCP (`PostFundManagement.Api`)**: Internal features, handlers, and workflows obtain in-process access to MCP tooling via `IInAppMcpClient`. Calls are executed directly against scoped services with zero network latency, zero serialization overhead, and no external web requests. + * **External MCP Server (`PostFundManagement.Mcp`)**: A dedicated standalone presentation host exposes the exact same tools over standard external transports (HTTP/SSE at `/mcp` and command-line Stdio via `--stdio`) for external LLMs, agentic workbenches, and client IDEs. + +2. **Application.Services Proxying Pattern**: + All tools reside in `PostFundManagement.Application/Mcp` and proxy established `Application.Services` interfaces (`IGrantsService`, `IProjectService`, `IExecutionService`, `IGovernanceService`, `IEvidenceService`, `ICommunicationsService`, `IUserService`). This ensures: + * **Single Source of Truth (BR-005)**: Business validation, entity mapping, and audit logging happen identically whether called from an HTTP endpoint, an internal in-app MCP consumer, or an external MCP server. + * **Domain Groupings**: Tools are partitioned by service domains (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`), providing clean tool namespaces (e.g., `grants_create_award`, `execution_approve_disbursement`). + * **Token Usage Optimization**: Responses are wrapped in concise `McpToolResult` records, filtering out internal framework metadata and returning concise JSON structures to minimize LLM context window consumption. + +3. **Current Agentic Boundary**: + The platform establishes the full tooling layer and in-app client contracts; no autonomous agents are defined in this release, preserving clear architectural boundaries for scheduled future agent implementations. diff --git a/Directory.Build.props b/Directory.Build.props index 055150f..55e506c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -85,6 +85,16 @@ + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 60c22b7..c9179d6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,6 +48,11 @@ + + + + + diff --git a/GEMINI.md b/GEMINI.md index 49b7fe2..275a740 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -70,7 +70,8 @@ PostFundManagement/ ├── PostFundManagement.Domain/ # Core entities, enums, value objects, domain logic ├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL (Npgsql), Migrations, Data Protection ├── PostFundManagement.Application/ # CQRS Handlers, Mediator, DTOs, Validations, S3, Email, Quartz -├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, Versioning +├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, In-App MCP Consumer +├── PostFundManagement.Mcp/ # Dedicated Model Context Protocol (MCP) Server Host (HTTP/SSE & Stdio Transports) ├── PostFundManagement.Jobs/ # Dedicated Quartz.NET Background Worker Host (Recurring & One-Time Execution) ├── PostFundManagement.Tests/ # Unit & Integration test suite (xUnit, Coverlet) ├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite 6 + Tailwind 4) @@ -86,7 +87,8 @@ PostFundManagement/ | **`PostFundManagement.Domain`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Humanizer` 3.0.10, `Hashids.net` 1.7.0, `OpenTelemetry` 1.18.0, `Quartz` 4.1.0, `FluentResults` 4.0.0, `Mediator.Abstractions` 3.0.2, `AWSSDK.S3` 4.0.103.3, `MailKit`/`MimeKit` 4.18.0 | Contains domain entities, aggregate roots, value objects, domain enums, constant definitions, and interfaces. Clean of presentation concerns. | | **`PostFundManagement.Infrastructure`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Duende.AccessTokenManagement` 4.2.0, EF Core Tools & Design | Houses `PostFundManagementDbContext`, EF Core fluent configurations, entity-to-table mappings, PostgreSQL schema definitions, multitenancy filters, and migrations. | | **`PostFundManagement.Application`** | `net10.0` | `Mediator.SourceGenerator` / `Mediator.Abstractions` 3.0.2, `FluentResults` 4.0.0, `Microsoft.Extensions.Caching.Hybrid` 10.10.0, `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.12, `Refit` 15.2.0, `Duende.AccessTokenManagement.OpenIdConnect` 4.2.0, `Quartz` 4.1.0, `AWSSDK.S3` | Implements CQRS command/query handlers, domain validators, S3 object storage clients (Garage S3), background jobs (Quartz), notification dispatchers, and business logic. | -| **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Handles routing, auth policies, error problem details, and OpenAPI metadata. | +| **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `ModelContextProtocol` 2.3.0, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Features direct in-app MCP tooling access (`IInAppMcpClient`) for in-process tool execution without external network calls. Handles routing, auth policies, error problem details, and OpenAPI metadata. | +| **`PostFundManagement.Mcp`** | `net10.0` | `ModelContextProtocol.AspNetCore` 2.3.0, `ModelContextProtocol` 2.3.0, `ModelContextProtocol.Core` 2.3.0, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Dedicated Model Context Protocol (MCP) Server host. Realises and serves tools defined in `Application.Mcp` externally over HTTP/SSE (`/mcp`) and Stdio (`--stdio`) for external LLMs, agent frameworks, and IDEs. | | **`PostFundManagement.Jobs`** | `net10.0` | `Quartz` 4.6.2, `Quartz.Extensions.DependencyInjection` 4.6.2, `Quartz.Serialization.SystemTextJson` 4.6.2, `Mediator.Abstractions` 3.0.2 | Dedicated background execution host. Executes recurring Quartz jobs (registered on startup by `JobsWorker`), processes asynchronous domain events (`IEvent`), and executes jobs concurrently (`MaxConcurrency = 5`). | | **`PostFundManagement.Tests`** | `net10.0` | `xunit` 2.9.3, `xunit.runner.visualstudio` 3.1.4, `Microsoft.NET.Test.Sdk` 17.14.1, `coverlet.collector` 6.0.4 | Unit and integration test coverage for domain rules, CQRS handlers, financial calculation models, and gate condition validations. | @@ -146,6 +148,68 @@ PostFundManagement/ 3. *Email Notification Processing*: Batches and dispatches pending notification entries (`Platform.Email` / `NotificationPlatform.Email`) via MailKit/MimeKit SMTP and records delivery telemetry (`FR-M25-001`). 4. *Future Jobs*: Seamlessly supports future scheduled workloads (e.g. drawdown reconciliations, compliance expiries, portfolio aggregations) simply by adding `IEvent` handlers. +### 2.5 Model Context Protocol (MCP) Architecture (Internal In-App & External Server Topology) +The platform establishes a dual-channel Model Context Protocol (MCP) architecture on **.NET 10** using `ModelContextProtocol.AspNetCore` (v2.3.0): + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ MCP TOOLING CONSUMERS │ +│ │ +│ [External AI Agents / Claude / Cursor] [Internal PFM API Flows] │ +│ │ │ │ +│ HTTP / SSE │ Stdio (--stdio) │ In-Process │ +│ (/mcp) │ │ Direct Call │ +│ ▼ ▼ │ +│ ┌─────────────────────────┐ ┌─────────────────────┐ │ +│ │ PostFundManagement.Mcp │ │PostFundManagement. │ │ +│ │ (Dedicated Server Host) │ │Api (In-App Consumer)│ │ +│ └────────────┬────────────┘ └──────────┬──────────┘ │ +│ │ │ │ +│ │ Direct DI / Component Reference │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ PostFundManagement.Application/Mcp │ │ +│ │ │ │ +│ │ [Grants] [Projects] [Execution] [Governance] │ │ +│ │ [Evidence] [Communications][Users] │ │ +│ │ Proxying: Application.Services.* │ │ +│ │ InApp Client: IInAppMcpClient / InAppMcpClient │ │ +│ └──────────────────────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Application Domain Services │ │ +│ │ IGrantsService, IProjectService, IExecutionService, ... │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +1. **Tool Definitions Centralized in `Application.Mcp`**: + - All MCP tools are defined in `PostFundManagement.Application` under the `Mcp` directory. + - Tools are grouped strictly according to `Application.Services` boundaries: + * `GrantsMcpTools`: Proxies `IGrantsService` (Awards M01, Contracts M02, Budgets M05). + * `ProjectsMcpTools`: Proxies `IProjectService` (WBS Activities M04, Milestones M07, Beneficiaries M13, Changes M15, Issues M16, Site Visits M17). + * `ExecutionMcpTools`: Proxies `IExecutionService` (Disbursements M06, Risk Register M09, Gating Rules BR-003). + * `GovernanceMcpTools`: Proxies `IGovernanceService` (Organisations M03, Portfolios, Instruments BR-006, Tenants, Dashboards M20/M22). + * `EvidenceMcpTools`: Proxies `IEvidenceService` (Audit Artifacts BR-004, M&E Indicators M08, Target/Actual Variance BR-009). + * `CommunicationsMcpTools`: Proxies `ICommunicationsService` (Notifications M25, Onboarding Invites M03). + * `UsersMcpTools`: Proxies `IUserService` (Identity accounts, RBAC roles, security claims). + +2. **In-App MCP Consumer in `PostFundManagement.Api` (Zero Web Hops)**: + - `PostFundManagement.Api` accesses tools in-process via `IInAppMcpClient` registered by `AddApplicationMcpTools()`. + - Resolves and executes tool methods directly against scoped domain services without issuing HTTP network calls to an external MCP server. + - Provides optional in-process protocol compatibility via `CreateProtocolClientAsync()` using duplex in-memory streams. + - *Design Boundary*: No autonomous AI agents are defined in the solution at this time; the tooling layer and in-app consumer interfaces provide the underlying foundation for future agentic orchestration. + +3. **External MCP Server in `PostFundManagement.Mcp`**: + - Dedicated Web/CLI host running .NET 10. + - Exposes tools externally via HTTP/SSE (`/mcp`) for remote agents, orchestrators, and platforms. + - Supports local Stdio transport (`--stdio`) for desktop agents, Claude Desktop, and IDE tools. + - Docker containerized (`pfm-mcp` service on port 8079). + +4. **Token Usage Optimization**: + - Tools return standardized, lightweight `McpToolResult` and `McpToolResult` models. + - Strips internal FluentResults metadata and complex execution graphs to ensure minimal prompt token bloat for consuming LLMs. + --- ## 3. Master Business Rules (BR-001 to BR-010) diff --git a/PostFundManagement.Api/PostFundManagement.Api.csproj b/PostFundManagement.Api/PostFundManagement.Api.csproj index 3098cb4..b66ac42 100644 --- a/PostFundManagement.Api/PostFundManagement.Api.csproj +++ b/PostFundManagement.Api/PostFundManagement.Api.csproj @@ -31,9 +31,10 @@ - + + diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index d8b78e5..cad77b0 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddEndpoints(Assembly.GetExecutingAssembly()); builder.Services.AddApiServices(builder.Configuration); builder.Services.AddSharedServices(); builder.Services.AddRegisterServicesWithCaching(builder.Configuration); +builder.Services.AddApplicationMcpTools(); builder.Services.AddEndpointAndServiceCaching(builder.Configuration); builder.Services.AddMediator(); diff --git a/PostFundManagement.Application/Abstractions/Mcp/IInAppMcpClient.cs b/PostFundManagement.Application/Abstractions/Mcp/IInAppMcpClient.cs new file mode 100644 index 0000000..1d12495 --- /dev/null +++ b/PostFundManagement.Application/Abstractions/Mcp/IInAppMcpClient.cs @@ -0,0 +1,29 @@ +using ModelContextProtocol.Client; +using PostFundManagement.Application.Mcp; + +namespace PostFundManagement.Application.Abstractions.Mcp; + +/// +/// In-app MCP access interface providing in-process tool discovery and execution without network or HTTP hops. +/// +public interface IInAppMcpClient +{ + /// + /// Lists all available MCP tools defined across Application.Services groupings. + /// + IReadOnlyList GetAvailableTools(); + + /// + /// Directly executes an in-app MCP tool by name using the current service scope. + /// + Task ExecuteToolAsync( + string toolName, + IReadOnlyDictionary? arguments = null, + CancellationToken cancellationToken = default); + + /// + /// Creates a fully functional standard ModelContextProtocol McpClient connected in-process over in-memory streams. + /// + Task CreateProtocolClientAsync(CancellationToken cancellationToken = default); +} + diff --git a/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs b/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs index b410f13..3597371 100644 --- a/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs +++ b/PostFundManagement.Application/Abstractions/Services/ICommunicationsService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Application.Services.Communications; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs b/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs index 92a7372..d495a7f 100644 --- a/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Application.Services.Evidence; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IExecutionService.cs b/PostFundManagement.Application/Abstractions/Services/IExecutionService.cs index c07921b..692521d 100644 --- a/PostFundManagement.Application/Abstractions/Services/IExecutionService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IExecutionService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs b/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs index 8bbb97c..f90f384 100644 --- a/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Application.Services.Governance; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs b/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs index 86542bc..0003c57 100644 --- a/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Application.Services.Grants; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IIdentityService.cs b/PostFundManagement.Application/Abstractions/Services/IIdentityService.cs index b3f033e..7ddb100 100644 --- a/PostFundManagement.Application/Abstractions/Services/IIdentityService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IIdentityService.cs @@ -1,4 +1,5 @@ using PostFundManagement.Domain.Attributes; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IProjectService.cs b/PostFundManagement.Application/Abstractions/Services/IProjectService.cs index 128349f..180e386 100644 --- a/PostFundManagement.Application/Abstractions/Services/IProjectService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IProjectService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Application.Services.Projects; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/IUserService.cs b/PostFundManagement.Application/Abstractions/Services/IUserService.cs index 1666db4..8718612 100644 --- a/PostFundManagement.Application/Abstractions/Services/IUserService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IUserService.cs @@ -3,6 +3,8 @@ using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models.Security; +using Result = FluentResults.Result; +using Role = PostFundManagement.Domain.Models.Security.Role; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Abstractions/Services/S3ServiceBase.cs b/PostFundManagement.Application/Abstractions/Services/S3ServiceBase.cs index e47b964..1a6456c 100644 --- a/PostFundManagement.Application/Abstractions/Services/S3ServiceBase.cs +++ b/PostFundManagement.Application/Abstractions/Services/S3ServiceBase.cs @@ -1,5 +1,6 @@ using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain.Errors; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Abstractions.Services; diff --git a/PostFundManagement.Application/Extensions/Mcp.cs b/PostFundManagement.Application/Extensions/Mcp.cs new file mode 100644 index 0000000..b96dcf5 --- /dev/null +++ b/PostFundManagement.Application/Extensions/Mcp.cs @@ -0,0 +1,58 @@ +using PostFundManagement.Application.Abstractions.Mcp; +using PostFundManagement.Application.Mcp; +using Result = FluentResults.Result; + +namespace PostFundManagement.Application.Extensions; + +public static class Mcp +{ + public static Records ToMcpResult(this Result result) + { + if (result.IsSuccess) + return Records.Ok(result.Value); + + var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message)); + var errorList = result.Errors.Select(e => e.Message).ToList(); + + return Records.Fail(errorMsg, errorList); + } + + public static McpToolResult ToMcpResult(this Result result, string successMessage = "Operation completed successfully.") + { + if (result.IsSuccess) + return McpToolResult.Ok(successMessage); + + var errorMsg = string.Join("; ", result.Errors.Select(e => e.Message)); + var errorList = result.Errors.Select(e => e.Message).ToList(); + + return McpToolResult.Fail(errorMsg, errorList); + } + + public static IServiceCollection AddApplicationMcpTools(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + + return services; + } + + public static IMcpServerBuilder WithApplicationMcpTools(this IMcpServerBuilder builder) + { + builder.WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + + return builder; + } +} diff --git a/PostFundManagement.Application/Mcp/CommunicationsMcpTools.cs b/PostFundManagement.Application/Mcp/CommunicationsMcpTools.cs new file mode 100644 index 0000000..4715768 --- /dev/null +++ b/PostFundManagement.Application/Mcp/CommunicationsMcpTools.cs @@ -0,0 +1,49 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Communications; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Communications, Onboarding Invites & Notification Services (Module M25, Recipient Onboarding M03). +/// +[McpServerToolType] +public class CommunicationsMcpTools(ICommunicationsService service) +{ + [McpServerTool(Name = "communications_get_notifications", Title = "Get Organisation Notifications", ReadOnly = true)] + [Description("Retrieves notifications and alerts dispatched to an organisation.")] + public async Task> GetNotificationsAsync([Description("Organisation hash identifier")] string organisationIdHash, CancellationToken cancellationToken = default) => + (await service.GetNotificationsAsync(organisationIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "communications_get_user_notifications", Title = "Get User Notifications", ReadOnly = true)] + [Description("Retrieves pending notifications and task reminders for a specific user.")] + public async Task> GetUserNotificationsAsync([Description("Target user identifier")] Guid userId, CancellationToken cancellationToken = default) => + (await service.GetUserNotificationsAsync(userId, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "communications_create_notification", Title = "Dispatch Notification", Destructive = false)] + [Description("Creates and queues a system, in-app, or email notification for delivery (FR-M25-001).")] + public async Task CreateNotificationAsync([Description("Notification creation request")] CreateNotificationRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateNotificationAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Notification successfully created and queued for delivery."); + + [McpServerTool(Name = "communications_update_notification", Title = "Update Notification Status", Destructive = false)] + [Description("Updates the delivery or read status of a notification.")] + public async Task UpdateNotificationAsync([Description("Notification update request")] UpdateNotificationRequest request, CancellationToken cancellationToken = default) => + (await service.UpdateNotificationAsync(request, cancellationToken)).ToMcpResult("Notification status successfully updated."); + + [McpServerTool(Name = "communications_send_invite", Title = "Send Onboarding Invite", Destructive = false)] + [Description("Sends an invitation email and onboarding link to an external grantee or partner organisation user (Module M03).")] + public async Task> SendInviteAsync([Description("Invite request parameters")] InviteRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.SendInviteAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "communications_get_invites", Title = "Get Onboarding Invites", ReadOnly = true)] + [Description("Retrieves a paginated list of sent onboarding invitations and their verification states.")] + public async Task> GetInvitesAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetInvitesAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "communications_get_applicant_data", Title = "Get Applicant Intake Data", ReadOnly = true)] + [Description("Retrieves captured applicant and grantee profile data for onboarding validation.")] + public async Task> GetApplicantDataAsync([Description("Applicant user identifier")] Guid applicantUserId, CancellationToken cancellationToken = default) => + (await service.GetApplicantDataAsync(applicantUserId, cancellationToken)).ToMcpResult(); +} diff --git a/PostFundManagement.Application/Mcp/EvidenceMcpTools.cs b/PostFundManagement.Application/Mcp/EvidenceMcpTools.cs new file mode 100644 index 0000000..bb0718f --- /dev/null +++ b/PostFundManagement.Application/Mcp/EvidenceMcpTools.cs @@ -0,0 +1,70 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Evidence; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Evidence, Indicators & Verification Services (BR-004 Evidence-Based Performance, M07 Milestones, M08 M&E Indicators). +/// +[McpServerToolType] +public class EvidenceMcpTools(IEvidenceService service) +{ + [McpServerTool(Name = "evidence_get_files", Title = "Get Uploaded Evidence Records", ReadOnly = true)] + [Description("Retrieves a paginated list of uploaded evidence documents and audit artifacts (BR-004, Module M07).")] + public async Task> GetEvidenceFilesAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetEvidenceFilesAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_get_evidence", Title = "Get Evidence by ID", ReadOnly = true)] + [Description("Retrieves metadata, verification status, and hash verification for an evidence document.")] + public async Task> GetEvidenceAsync([Description("Evidence hash identifier")] string evidenceIdHash, CancellationToken cancellationToken = default) => + (await service.GetEvidenceAsync(evidenceIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_get_milestone_evidence", Title = "Get Milestone Evidence", ReadOnly = true)] + [Description("Retrieves all evidence documents uploaded to substantiate a specific milestone.")] + public async Task> GetMilestoneEvidenceAsync([Description("Milestone hash identifier")] string milestoneIdHash, CancellationToken cancellationToken = default) => + (await service.GetMilestoneEvidencesAsync(milestoneIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_get_project_evidence", Title = "Get Project Evidence", ReadOnly = true)] + [Description("Retrieves all evidence documents associated with a project across all its milestones and indicators.")] + public async Task> GetProjectEvidenceAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetProjectEvidencesAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_get_url", Title = "Get Evidence Document URL", ReadOnly = true)] + [Description("Generates a secure presigned download URL for an uploaded evidence artifact in object storage.")] + public async Task> GetEvidenceUrlAsync([Description("Evidence hash identifier")] string evidenceIdHash, CancellationToken cancellationToken = default) => + (await service.GetEvidenceUrlAsync(evidenceIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_get_indicators", Title = "Get M&E Indicators", ReadOnly = true)] + [Description("Retrieves a paginated list of M&E indicators measuring project results hierarchy (Module M08, BR-008, BR-009).")] + public async Task> GetIndicatorsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetIndicatorsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_get_indicator", Title = "Get Indicator by ID", ReadOnly = true)] + [Description("Retrieves baseline, target, actual values, and variance calculation for an indicator (BR-009).")] + public async Task> GetIndicatorAsync([Description("Indicator hash identifier")] string indicatorIdHash, CancellationToken cancellationToken = default) => + (await service.GetIndicatorAsync(indicatorIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "evidence_create_indicator", Title = "Create Performance Indicator", Destructive = false)] + [Description("Creates an M&E indicator measuring Output, Outcome, or Impact with baseline and targets (Module M08, BR-008).")] + public async Task CreateIndicatorAsync([Description("Indicator creation request")] CreateIndicatorRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateIndicatorAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Indicator successfully created."); + + [McpServerTool(Name = "evidence_record_actual_amount", Title = "Record Indicator Actual Value", Destructive = false)] + [Description("Records achieved actual metric and cost for an indicator, recalculating variance against target (BR-009).")] + public async Task RecordIndicatorActualAmountAsync([Description("Indicator hash identifier")] string indicatorIdHash, [Description("Actual numerical value achieved")] decimal amount, [Description("Optional actual financial cost incurred")] decimal? actualCost = null, + [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.RecordIndicatorActualAmountAsync(indicatorIdHash, amount, actualCost, userId ?? SystemUser, cancellationToken)).ToMcpResult("Indicator actual value successfully recorded."); + + [McpServerTool(Name = "evidence_create_milestone", Title = "Create Milestone with Gating Criteria", Destructive = false)] + [Description("Defines a project milestone with verification evidence rules gating tranche disbursements (Module M07, BR-003).")] + public async Task CreateMilestoneAsync([Description("Milestone creation request")] CreateMilestoneRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateMilestoneAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Milestone successfully created."); + + [McpServerTool(Name = "evidence_update_evidence", Title = "Update Evidence Metadata", Destructive = false)] + [Description("Updates verification status, review notes, or metadata for an evidence submission.")] + public async Task UpdateEvidenceAsync([Description("Evidence update request")] UpdateEvidenceRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateEvidenceAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Evidence metadata successfully updated."); +} diff --git a/PostFundManagement.Application/Mcp/ExecutionMcpTools.cs b/PostFundManagement.Application/Mcp/ExecutionMcpTools.cs new file mode 100644 index 0000000..a04b4c8 --- /dev/null +++ b/PostFundManagement.Application/Mcp/ExecutionMcpTools.cs @@ -0,0 +1,79 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Execution; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Execution Services (M06 Disbursement Control, M09 Risk Management, Gating Rules BR-003). +/// +[McpServerToolType] +public class ExecutionMcpTools(IExecutionService service) +{ + [McpServerTool(Name = "execution_get_disbursements", Title = "Get Disbursements", ReadOnly = true)] + [Description("Retrieves a paginated list of disbursement tranche and drawdown records (Module M06).")] + public async Task> GetDisbursementsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetDisbursementsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "execution_get_disbursement", Title = "Get Disbursement by ID", ReadOnly = true)] + [Description("Retrieves details of a disbursement request by its hash identifier.")] + public async Task> GetDisbursementAsync([Description("Disbursement hash identifier")] string disbursementIdHash, CancellationToken cancellationToken = default) => + (await service.GetDisbursementAsync(disbursementIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "execution_create_disbursement", Title = "Create Disbursement Request", Destructive = false)] + [Description("Submits a drawdown / payment disbursement request linked to a satisfied milestone (BR-003, Module M06).")] + public async Task CreateDisbursementAsync([Description("Disbursement request details")] CreateDisbursementRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateDisbursementAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Disbursement request successfully created."); + + [McpServerTool(Name = "execution_approve_disbursement", Title = "Approve Disbursement", Destructive = false)] + [Description("Formally approves release of funds for a disbursement tranche after verifying milestone evidence and conditions precedent (BR-003, AT-03).")] + public async Task ApproveDisbursementAsync([Description("Disbursement hash identifier")] string disbursementIdHash, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.ApproveDisbursementAsync(disbursementIdHash, userId ?? SystemUser, cancellationToken)).ToMcpResult("Disbursement tranche successfully approved for release."); + + [McpServerTool(Name = "execution_update_disbursement", Title = "Update Disbursement", Destructive = false)] + [Description("Updates metadata, bank payment references, or status of a disbursement tranche.")] + public async Task UpdateDisbursementAsync([Description("Disbursement update request")] UpdateDisbursementRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateDisbursementAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Disbursement record successfully updated."); + + [McpServerTool(Name = "execution_get_risks", Title = "Get Risk Register", ReadOnly = true)] + [Description("Retrieves a paginated list of risks identified across portfolio projects (Module M09).")] + public async Task> GetRisksAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetRisksAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "execution_get_risk", Title = "Get Risk by ID", ReadOnly = true)] + [Description("Retrieves details of a project risk, including likelihood, impact, score, and mitigations.")] + public async Task> GetRiskAsync([Description("Risk hash identifier")] string riskIdHash, CancellationToken cancellationToken = default) => + (await service.GetRiskAsync(riskIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "execution_create_risk", Title = "Log Risk", Destructive = false)] + [Description("Logs a new risk event with likelihood, severity, residual risk, and mitigation action plan (Module M09).")] + public async Task CreateRiskAsync([Description("Risk log request")] CreateRiskRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateRiskAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Risk record successfully created."); + + [McpServerTool(Name = "execution_update_risk", Title = "Update Risk & Mitigation", Destructive = false)] + [Description("Updates a risk's assessment score, status, or mitigation progress.")] + public async Task UpdateRiskAsync([Description("Risk update parameters")] UpdateRiskRequest request, CancellationToken cancellationToken = default) => + (await service.UpdateRiskAsync(request, cancellationToken)).ToMcpResult("Risk record successfully updated."); + + [McpServerTool(Name = "execution_get_rules", Title = "Get Gating Rules", ReadOnly = true)] + [Description("Retrieves configurable gating and compliance validation rules (Module M06 / BR-003).")] + public async Task> GetRulesAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetRulesAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "execution_get_rule", Title = "Get Rule by ID", ReadOnly = true)] + [Description("Retrieves details of a gating validation rule by hash identifier.")] + public async Task> GetRuleAsync([Description("Rule hash identifier")] string ruleIdHash, CancellationToken cancellationToken = default) => + (await service.GetRuleAsync(ruleIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "execution_create_rule", Title = "Create Gating Rule", Destructive = false)] + [Description("Defines a new business or compliance gating rule governing disbursement release.")] + public async Task CreateRuleAsync([Description("Rule creation request")] CreateRuleRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateRuleAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Gating rule successfully created."); + + [McpServerTool(Name = "execution_update_rule", Title = "Update Gating Rule", Destructive = false)] + [Description("Updates the logic or parameters of an existing gating rule.")] + public async Task UpdateRuleAsync([Description("Rule update request")] UpdateRuleRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateRuleAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Gating rule successfully updated."); +} diff --git a/PostFundManagement.Application/Mcp/GovernanceMcpTools.cs b/PostFundManagement.Application/Mcp/GovernanceMcpTools.cs new file mode 100644 index 0000000..d2cee6e --- /dev/null +++ b/PostFundManagement.Application/Mcp/GovernanceMcpTools.cs @@ -0,0 +1,98 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Governance; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Governance Services (Portfolios, Organisations, Multi-Tenancy, Funding Instruments, Executive Dashboards). +/// +[McpServerToolType] +public class GovernanceMcpTools(IGovernanceService service) +{ + [McpServerTool(Name = "governance_get_dashboard", Title = "Get Portfolio Executive Dashboard", ReadOnly = true)] + [Description("Retrieves aggregated executive portfolio statistics, project counts, financial totals, and risk overviews (Modules M20 & M22).")] + public async Task> GetDashboardAsync(CancellationToken cancellationToken = default) => + (await service.GetDashboardAsync(cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_get_organisations", Title = "Get Master Organisations", ReadOnly = true)] + [Description("Retrieves a paginated list of recipient, partner, and donor organisations (BR-005 Single Source of Truth).")] + public async Task> GetOrganisationsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetOrganisationsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_get_organisation", Title = "Get Organisation by ID", ReadOnly = true)] + [Description("Retrieves full master data and KYC details for an organisation by hash identifier.")] + public async Task> GetOrganisationAsync([Description("Organisation hash identifier")] string organisationIdHash, CancellationToken cancellationToken = default) => + (await service.GetOrganisationAsync(organisationIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_create_organisation", Title = "Register Organisation", Destructive = false)] + [Description("Registers a new recipient organisation, donor, or implementation partner (Module M03).")] + public async Task CreateOrganisationAsync([Description("Organisation creation details")] CreateOrganisationRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateOrganisationAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Organisation successfully registered."); + + [McpServerTool(Name = "governance_update_organisation", Title = "Update Organisation", Destructive = false)] + [Description("Updates organisation profile, contact details, registration, or tax status.")] + public async Task> UpdateOrganisationAsync([Description("Organisation update request")] UpdateOrganisationRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) + { + var result = await service.UpdateOrganisationAsync(request, userId ?? SystemUser, cancellationToken); + + return result.ToMcpResult(); + } + + [McpServerTool(Name = "governance_get_portfolios", Title = "Get Portfolios", ReadOnly = true)] + [Description("Retrieves a paginated list of top-level investment and grant portfolios.")] + public async Task> GetPortfoliosAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetPorfoliosAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_get_portfolio", Title = "Get Portfolio by ID", ReadOnly = true)] + [Description("Retrieves details of a portfolio by hash identifier.")] + public async Task> GetPortfolioAsync([Description("Portfolio hash identifier")] string portfolioIdHash, CancellationToken cancellationToken = default) => + (await service.GetPortfolioAsync(portfolioIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_create_portfolio", Title = "Create Portfolio", Destructive = false)] + [Description("Creates an overarching investment or grant portfolio governing programmes.")] + public async Task CreatePortfolioAsync([Description("Portfolio creation parameters")] CreatePortfolioRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreatePortfolioAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Portfolio successfully created."); + + [McpServerTool(Name = "governance_update_portfolio", Title = "Update Portfolio", Destructive = false)] + [Description("Updates portfolio objectives, dates, or metadata.")] + public async Task UpdatePortfolioAsync([Description("Portfolio update request")] UpdatePortfolioRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdatePortfolioAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Portfolio successfully updated."); + + [McpServerTool(Name = "governance_get_instruments", Title = "Get Funding Instruments", ReadOnly = true)] + [Description("Retrieves configured funding instruments (Grant, Debt, Impact, Blended Finance, etc.) under a programme (BR-006).")] + public async Task> GetInstrumentsAsync([Description("Programme hash identifier")] string programmeIdHash, CancellationToken cancellationToken = default) => + (await service.GetInstrumentsAsync(programmeIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_create_instrument", Title = "Define Funding Instrument", Destructive = false)] + [Description("Configures a funding instrument with custom milestone rules, KPIs, and disbursement criteria (BR-006).")] + public async Task CreateInstrumentAsync([Description("Instrument creation request")] CreateInstrumentRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateInstrumentAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Funding instrument successfully defined."); + + [McpServerTool(Name = "governance_get_tenants", Title = "Get Tenants", ReadOnly = true)] + [Description("Retrieves registered tenant partitions in the multi-tenant architecture.")] + public async Task> GetTenantsAsync(CancellationToken cancellationToken = default) => + (await service.GetTenantsAsync(cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_get_tenant", Title = "Get Tenant by ID", ReadOnly = true)] + [Description("Retrieves tenant profile and isolation partition details.")] + public async Task> GetTenantAsync([Description("Tenant hash identifier")] string tenantIdHash, CancellationToken cancellationToken = default) => + (await service.GetTenantAsync(tenantIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_create_tenant", Title = "Create Tenant", Destructive = false)] + [Description("Provisions a new tenant boundary in the platform.")] + public async Task CreateTenantAsync([Description("Tenant creation parameters")] CreateTenantRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateTenantAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Tenant successfully created."); + + [McpServerTool(Name = "governance_get_portfolio_value", Title = "Calculate Portfolio Monetary Value", ReadOnly = true)] + [Description("Computes aggregated total commitment and monetary allocation across all projects in a portfolio (BR-010).")] + public async Task> GetPortfolioValueAsync([Description("Portfolio hash identifier")] string portfolioIdHash, CancellationToken cancellationToken = default) => + (await service.GetPorfolioValueAsync(portfolioIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "governance_get_programme_value", Title = "Calculate Programme Monetary Value", ReadOnly = true)] + [Description("Computes aggregated total commitment across all projects in a programme (BR-010).")] + public async Task> GetProgrammeValueAsync([Description("Programme hash identifier")] string programmeIdHash, CancellationToken cancellationToken = default) => + (await service.GetProgrammeValueAsync(programmeIdHash, cancellationToken)).ToMcpResult(); +} diff --git a/PostFundManagement.Application/Mcp/GrantsMcpTools.cs b/PostFundManagement.Application/Mcp/GrantsMcpTools.cs new file mode 100644 index 0000000..c9c9b2c --- /dev/null +++ b/PostFundManagement.Application/Mcp/GrantsMcpTools.cs @@ -0,0 +1,84 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Grants; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Grants & Award Services (M01 Award Intake, M02 Contract Management, M05 Budgeting). +/// +[McpServerToolType] +public class GrantsMcpTools(IGrantsService service) +{ + [McpServerTool(Name = "grants_get_awards", Title = "Get Approved Funding Awards", ReadOnly = true)] + [Description("Retrieves a paginated list of approved funding awards (Module M01).")] + public async Task> GetAwardsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetAwardsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_get_award", Title = "Get Award by ID", ReadOnly = true)] + [Description("Retrieves details of an approved funding award by its hash identifier.")] + public async Task> GetAwardAsync([Description("Award hash identifier")] string awardIdHash, CancellationToken cancellationToken = default) => + (await service.GetAwardAsync(awardIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_create_award", Title = "Register Approved Award", Destructive = false)] + [Description("Registers an approved funding award from upstream committee sanction (BR-001/BR-002, M01). Automatically transitions into contract generation.")] + public async Task CreateAwardAsync([Description("Award creation details")] CreateAwardRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateAwardAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Award record successfully created and transitioned to contract generation."); + + [McpServerTool(Name = "grants_update_award", Title = "Update Award Record", Destructive = false)] + [Description("Updates an existing funding award's metadata and parameters.")] + public async Task UpdateAwardAsync([Description("Award update parameters")] UpdateAwardRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateAwardAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Award record successfully updated."); + + [McpServerTool(Name = "grants_get_contracts", Title = "Get Contracts", ReadOnly = true)] + [Description("Retrieves a paginated list of legal grant contracts and agreements (Module M02).")] + public async Task> GetContractsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetContractsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_get_contract", Title = "Get Contract by ID", ReadOnly = true)] + [Description("Retrieves details of a contract agreement by its hash identifier.")] + public async Task> GetContractAsync([Description("Contract hash identifier")] string contractIdHash, CancellationToken cancellationToken = default) => + (await service.GetContractAsync(contractIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_create_contract", Title = "Create Draft Contract", Destructive = false)] + [Description("Creates a new draft contract or agreement associated with an approved award (Module M02).")] + public async Task CreateContractAsync([Description("Contract creation request")] CreateContractRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateContractAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Draft contract agreement successfully created."); + + [McpServerTool(Name = "grants_update_contract", Title = "Update Contract Agreement", Destructive = false)] + [Description("Updates terms and metadata of a contract in DRAFT or UNDER_REVIEW state.")] + public async Task UpdateContractAsync([Description("Contract update request")] UpdateContractRequest request, CancellationToken cancellationToken = default) => + (await service.UpdateContractAsync(request, cancellationToken)).ToMcpResult("Contract agreement successfully updated."); + + [McpServerTool(Name = "grants_execute_contract", Title = "Formally Execute Contract", Destructive = false)] + [Description("Formally executes and locks a contract agreement once all parties have signed and effective date is established (FR-M02-020).")] + public async Task ExecuteContractAsync([Description("Contract execution request")] ExecuteContractRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.ExecuteContractAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Contract agreement successfully executed and locked."); + + [McpServerTool(Name = "grants_get_contract_url", Title = "Get Contract Download URL", ReadOnly = true)] + [Description("Retrieves a presigned download URL for the authoritative executed contract document.")] + public async Task> GetContractUrlAsync([Description("Contract hash identifier")] string contractIdHash, CancellationToken cancellationToken = default) => + (await service.GetContractUrlAsync(contractIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_get_budgets", Title = "Get Budgets", ReadOnly = true)] + [Description("Retrieves a paginated list of project budgets and allocations (Module M05).")] + public async Task> GetBudgetsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetBudgetsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_get_budget", Title = "Get Budget by ID", ReadOnly = true)] + [Description("Retrieves details of a project budget allocation by hash identifier.")] + public async Task> GetBudgetAsync([Description("Budget hash identifier")] string budgetIdHash, CancellationToken cancellationToken = default) => + (await service.GetBudgetAsync(budgetIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "grants_create_budget", Title = "Create Budget Allocation", Destructive = false)] + [Description("Creates a new project budget line and category allocation (Module M05).")] + public async Task CreateBudgetAsync([Description("Budget creation request")] CreateBudgetRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateBudgetAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Budget allocation successfully created."); + + [McpServerTool(Name = "grants_update_budget", Title = "Update Budget Allocation", Destructive = false)] + [Description("Updates an existing project budget allocation with audit tracking.")] + public async Task UpdateBudgetAsync([Description("Budget update request")] UpdateBudgetRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateBudgetAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Budget allocation successfully updated."); +} diff --git a/PostFundManagement.Application/Mcp/InAppMcpClient.cs b/PostFundManagement.Application/Mcp/InAppMcpClient.cs new file mode 100644 index 0000000..0c801d6 --- /dev/null +++ b/PostFundManagement.Application/Mcp/InAppMcpClient.cs @@ -0,0 +1,222 @@ +using PostFundManagement.Application.Abstractions.Mcp; + +namespace PostFundManagement.Application.Mcp; + +public class InAppMcpClient : IInAppMcpClient +{ + private static bool initialized; + + private readonly IServiceProvider serviceProvider; + private readonly ILoggerFactory loggerFactory; + + private static readonly ConcurrentDictionary ToolRegistry = new(StringComparer.OrdinalIgnoreCase); + private static readonly List ToolDefinitions = []; + private static readonly Lock InitLock = new(); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public static readonly Type[] ToolTypes = + [ + typeof(GrantsMcpTools), + typeof(ProjectsMcpTools), + typeof(ExecutionMcpTools), + typeof(GovernanceMcpTools), + typeof(EvidenceMcpTools), + typeof(CommunicationsMcpTools), + typeof(UsersMcpTools) + ]; + + public InAppMcpClient(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) + { + this.serviceProvider = serviceProvider; + this.loggerFactory = loggerFactory; + + EnsureInitialized(); + } + + private static void EnsureInitialized() + { + if (initialized) return; + + lock (InitLock) + { + if (initialized) return; + + foreach (var toolType in ToolTypes) + { + var groupName = toolType.Name.Replace("McpTools", string.Empty); + + foreach (var method in toolType.GetMethods(BindingFlags.Public | BindingFlags.Instance)) + { + var toolAttr = method.GetCustomAttribute(); + + if (toolAttr == null) continue; + + var toolName = !string.IsNullOrWhiteSpace(toolAttr.Name) ? toolAttr.Name : method.Name; + var title = !string.IsNullOrWhiteSpace(toolAttr.Title) ? toolAttr.Title : toolName; + var descAttr = method.GetCustomAttribute(); + var description = descAttr?.Description ?? title; + + var parameters = new List(); + var methodParams = method.GetParameters(); + + foreach (var p in methodParams) + { + if (p.ParameterType == typeof(CancellationToken)) continue; + + var pDescAttr = p.GetCustomAttribute(); + + parameters.Add(new McpToolParameter(Name: p.Name ?? "param", Type: p.ParameterType.Name, Description: pDescAttr?.Description ?? string.Empty, + IsRequired: !p.HasDefaultValue && !IsNullable(p.ParameterType), DefaultValue: p.HasDefaultValue ? p.DefaultValue : null)); + } + + var toolDef = new McpToolDefinition(Name: toolName, Title: title, Description: description, Group: groupName, + ReadOnly: toolAttr.ReadOnly, Parameters: parameters); + + ToolDefinitions.Add(toolDef); + + ToolRegistry[toolName] = new ToolMethodInfo(toolType, method, methodParams); + } + } + + initialized = true; + } + } + + public IReadOnlyList GetAvailableTools() => ToolDefinitions; + + public async Task ExecuteToolAsync(string toolName, IReadOnlyDictionary? arguments = null, CancellationToken cancellationToken = default) + { + if (!ToolRegistry.TryGetValue(toolName, out var toolMethod)) + return McpToolExecutionResult.Fail($"MCP tool '{toolName}' is not recognized."); + + try + { + var targetInstance = serviceProvider.GetRequiredService(toolMethod.TargetType); + var args = BindArguments(toolMethod.Parameters, arguments, cancellationToken); + + var invokeResult = toolMethod.Method.Invoke(targetInstance, args); + + if (invokeResult is Task task) + { + await task.ConfigureAwait(false); + + var resultProperty = task.GetType().GetProperty("Result"); + var value = resultProperty?.GetValue(task); + + return McpToolExecutionResult.Ok(value); + } + + return McpToolExecutionResult.Ok(invokeResult); + } + catch (TargetInvocationException ex) + { + var inner = ex.InnerException ?? ex; + + return McpToolExecutionResult.Fail($"Execution of tool '{toolName}' failed: {inner.Message}"); + } + catch (Exception ex) + { + return McpToolExecutionResult.Fail($"Execution of tool '{toolName}' failed: {ex.Message}"); + } + } + + public async Task CreateProtocolClientAsync(CancellationToken cancellationToken = default) + { + var serverToClient = new Pipe(); + var clientToServer = new Pipe(); + + var serverIn = clientToServer.Reader.AsStream(); + var serverOut = serverToClient.Writer.AsStream(); + + var clientIn = serverToClient.Reader.AsStream(); + var clientOut = clientToServer.Writer.AsStream(); + + var inMemoryServices = new ServiceCollection(); + inMemoryServices.AddLogging(); + + foreach (var t in ToolTypes) + inMemoryServices.AddScoped(t, _ => serviceProvider.GetRequiredService(t)); + + var mcpBuilder = inMemoryServices.AddMcpServer(); + + foreach (var t in ToolTypes) + mcpBuilder.WithTools(t, (JsonSerializerOptions?)null); + + mcpBuilder.WithStreamServerTransport(serverIn, serverOut); + + var inMemoryProvider = inMemoryServices.BuildServiceProvider(); + var hostedServices = inMemoryProvider.GetServices(); + + foreach (var hs in hostedServices) + _ = hs.StartAsync(cancellationToken); + + var clientTransport = new StreamClientTransport(serverInput: clientOut, serverOutput: clientIn, loggerFactory); + + return await McpClient.CreateAsync(clientTransport, cancellationToken: cancellationToken); + } + + private static object?[] BindArguments(ParameterInfo[] parameters, IReadOnlyDictionary? arguments, CancellationToken cancellationToken) + { + var result = new object?[parameters.Length]; + var args = arguments ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var i = 0; i < parameters.Length; i++) + { + var p = parameters[i]; + + if (p.ParameterType == typeof(CancellationToken)) + { + result[i] = cancellationToken; + + continue; + } + + var matchKey = args.Keys.FirstOrDefault(k => string.Equals(k, p.Name, StringComparison.OrdinalIgnoreCase)); + + if (matchKey != null && args.TryGetValue(matchKey, out var val) && val != null) + result[i] = ConvertValue(val, p.ParameterType); + else if (p.HasDefaultValue) + result[i] = p.DefaultValue; + else if (p.ParameterType == typeof(Guid) && p.Name?.Contains("userId", StringComparison.OrdinalIgnoreCase) == true) + result[i] = SystemUser; + else if (IsNullable(p.ParameterType)) + result[i] = null; + else + result[i] = p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null; + } + + return result; + } + + private static object? ConvertValue(object val, Type targetType) + { + if (val == null) return null; + + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlying.IsInstanceOfType(val)) return val; + + if (val is JsonElement jsonElement) + return JsonSerializer.Deserialize(jsonElement.GetRawText(), targetType, JsonOptions); + + var str = val.ToString(); + + if (str == null) return null; + + if (underlying == typeof(Guid)) return Guid.Parse(str); + + if (underlying.IsEnum) return Enum.Parse(underlying, str, true); + + if (underlying.IsPrimitive || underlying == typeof(decimal) || underlying == typeof(string) || underlying == typeof(DateTime)) + return Convert.ChangeType(val, underlying, CultureInfo.InvariantCulture); + + return JsonSerializer.Deserialize(JsonSerializer.Serialize(val), targetType, JsonOptions); + } + + private static bool IsNullable(Type type) => + !type.IsValueType || Nullable.GetUnderlyingType(type) != null; +} diff --git a/PostFundManagement.Application/Mcp/ProjectsMcpTools.cs b/PostFundManagement.Application/Mcp/ProjectsMcpTools.cs new file mode 100644 index 0000000..44a4a29 --- /dev/null +++ b/PostFundManagement.Application/Mcp/ProjectsMcpTools.cs @@ -0,0 +1,164 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Projects; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Project & Programme Management Services (M04 WBS/Activities, M07 Milestones, M10 Compliance, M13 Beneficiaries, M15 Change Control, M16 Issues, M17 Site Visits). +/// +[McpServerToolType] +public class ProjectsMcpTools(IProjectService service) +{ + [McpServerTool(Name = "projects_get_projects", Title = "Get Projects", ReadOnly = true)] + [Description("Retrieves a paginated list of funded projects (Module M04).")] + public async Task> GetProjectsAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetProjectsAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_project", Title = "Get Project by ID", ReadOnly = true)] + [Description("Retrieves comprehensive details of a project by its hash identifier.")] + public async Task> GetProjectAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetProjectAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_project", Title = "Create Project", Destructive = false)] + [Description("Creates and initializes a funded project under an active award/programme (Module M04).")] + public async Task CreateProjectAsync([Description("Project creation request")] CreateProjectRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateProjectAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Project successfully created."); + + [McpServerTool(Name = "projects_update_project", Title = "Update Project", Destructive = false)] + [Description("Updates project baseline configuration, scope, and objectives.")] + public async Task UpdateProjectAsync([Description("Project update request")] UpdateProjectRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateProjectAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Project details successfully updated."); + + [McpServerTool(Name = "projects_update_project_status", Title = "Update Project Status", Destructive = false)] + [Description("Transitions a project's lifecycle status (e.g. Inception, Active, Under Review, Closeout, Completed).")] + public async Task UpdateProjectStatusAsync([Description("Status update request")] UpdateProjectStatusRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateProjectStatusAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult("Project status successfully updated."); + + [McpServerTool(Name = "projects_get_programmes", Title = "Get Funding Programmes", ReadOnly = true)] + [Description("Retrieves a paginated list of overarching funding programmes.")] + public async Task> GetProgrammesAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetProgrammesAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_programme", Title = "Get Programme by ID", ReadOnly = true)] + [Description("Retrieves details of a funding programme by its hash identifier.")] + public async Task> GetProgrammeAsync([Description("Programme hash identifier")] string programmeIdHash, CancellationToken cancellationToken = default) => + (await service.GetProgrammeAsync(programmeIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_programme", Title = "Create Programme", Destructive = false)] + [Description("Creates a new funding programme governing awards and portfolios.")] + public async Task CreateProgrammeAsync([Description("Programme creation parameters")] CreateProgrammeRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateProgrammeAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_update_programme", Title = "Update Programme", Destructive = false)] + [Description("Updates details of an existing funding programme.")] + public async Task UpdateProgrammeAsync([Description("Programme update parameters")] UpdateProgrammeRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateProgrammeAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_programme_projects", Title = "Get Projects by Programme", ReadOnly = true)] + [Description("Retrieves all projects associated with a given funding programme.")] + public async Task> GetProgrammeProjectsAsync([Description("Programme hash identifier")] string programmeIdHash, CancellationToken cancellationToken = default) => + (await service.GetProgrammeProjectsAsync(programmeIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_activities", Title = "Get Project Activities", ReadOnly = true)] + [Description("Retrieves the work breakdown structure (WBS) activities for a project.")] + public async Task> GetActivitiesAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetActivitiesAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_activity", Title = "Get Activity by ID", ReadOnly = true)] + [Description("Retrieves details of a project activity by its hash identifier.")] + public async Task> GetActivityAsync([Description("Activity hash identifier")] string activityIdHash, CancellationToken cancellationToken = default) => + (await service.GetActivityAsync(activityIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_activity", Title = "Create Project Activity", Destructive = false)] + [Description("Creates a new work breakdown structure (WBS) activity under a project.")] + public async Task CreateActivityAsync([Description("Activity creation request")] CreateActivityRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateActivityAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_update_activity", Title = "Update Project Activity", Destructive = false)] + [Description("Updates progress, timeline, and status of a project activity.")] + public async Task UpdateActivityAsync([Description("Activity update request")] UpdateActivityRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateActivityAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_project_milestones", Title = "Get Project Milestones", ReadOnly = true)] + [Description("Retrieves all delivery and disbursement gating milestones for a project (Module M07).")] + public async Task> GetProjectMilestonesAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetProjectMilestonesAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_milestones", Title = "Get All Milestones", ReadOnly = true)] + [Description("Retrieves a paginated list of milestones across the portfolio.")] + public async Task> GetMilestonesAsync([Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetMilestonesAsync(new Pagination(page, pageSize), cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_milestone", Title = "Get Milestone by ID", ReadOnly = true)] + [Description("Retrieves details of a project milestone by its hash identifier.")] + public async Task> GetMilestoneAsync([Description("Milestone hash identifier")] string milestoneIdHash, CancellationToken cancellationToken = default) => + (await service.GetMilestoneAsync(milestoneIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_update_milestone", Title = "Update Milestone", Destructive = false)] + [Description("Updates milestone progress, delivery dates, verification status, and completion state.")] + public async Task UpdateMilestoneAsync([Description("Milestone update request")] UpdateMilestoneRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.UpdateMilestoneAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_project_risks", Title = "Get Project Risks", ReadOnly = true)] + [Description("Retrieves risk register items specifically identified for a project.")] + public async Task> GetProjectRisksAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetProjectRisksAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_project_indicators", Title = "Get Project Indicators", ReadOnly = true)] + [Description("Retrieves M&E performance indicators configured for a project (Module M08).")] + public async Task> GetProjectIndicatorsAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetProjectIndicatorsAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_beneficiaries", Title = "Get Beneficiaries", ReadOnly = true)] + [Description("Retrieves POPIA-compliant beneficiary demographic records for a project (Module M13).")] + public async Task> GetBeneficiariesAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetBeneficiariesAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_beneficiary", Title = "Register Beneficiary Group", Destructive = false)] + [Description("Registers reach and demographic beneficiary data for a project (Module M13).")] + public async Task CreateBeneficiaryAsync([Description("Beneficiary registration request")] CreateBeneficiaryRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateBeneficiaryAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_change_requests", Title = "Get Change Requests", ReadOnly = true)] + [Description("Retrieves project scope, timeline, and budget change requests (Module M15).")] + public async Task> GetChangeRequestsAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetChangeRequestsAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_change_request", Title = "Submit Change Request", Destructive = false)] + [Description("Submits a formal change request for project scope, budget, or timeline amendment.")] + public async Task CreateChangeRequestAsync([Description("Change request details")] CreateChangeRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateChangeRequestAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_compliance_items", Title = "Get Compliance Items", ReadOnly = true)] + [Description("Retrieves statutory, tax, and ESG compliance checklist items for a project (Module M10).")] + public async Task> GetComplianceItemsAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetComplianceItemsAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_compliance_item", Title = "Create Compliance Item", Destructive = false)] + [Description("Creates a compliance or statutory requirement item for monitoring.")] + public async Task CreateComplianceItemAsync([Description("Compliance item details")] CreateComplianceItemRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateComplianceItemAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_issues", Title = "Get Project Issues", ReadOnly = true)] + [Description("Retrieves operational issue log items and blockers for a project (Module M16).")] + public async Task> GetIssuesAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetIssuesAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_issue", Title = "Log Issue", Destructive = false)] + [Description("Logs an operational issue or bottleneck against a project for management tracking.")] + public async Task CreateIssueAsync([Description("Issue log request")] CreateIssueRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateIssueAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_get_site_visits", Title = "Get Site Visits", ReadOnly = true)] + [Description("Retrieves scheduled and completed physical or virtual monitoring site visits (Module M17).")] + public async Task> GetSiteVisitsAsync([Description("Project hash identifier")] string projectIdHash, CancellationToken cancellationToken = default) => + (await service.GetSiteVisitsAsync(projectIdHash, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "projects_create_site_visit", Title = "Schedule Site Visit", Destructive = false)] + [Description("Schedules an in-person or remote monitoring site inspection for milestone verification.")] + public async Task CreateSiteVisitAsync([Description("Site visit schedule request")] CreateSiteVisitRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateSiteVisitAsync(request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); +} diff --git a/PostFundManagement.Application/Mcp/Records.cs b/PostFundManagement.Application/Mcp/Records.cs new file mode 100644 index 0000000..dbf2d66 --- /dev/null +++ b/PostFundManagement.Application/Mcp/Records.cs @@ -0,0 +1,42 @@ +namespace PostFundManagement.Application.Mcp; + +/// +/// Token-optimized standardized tool response wrapper for MCP tool execution. +/// +public record Records(bool Success, T? Data = default, string? Message = null, IReadOnlyList? Errors = null) +{ + public static Records Ok(T data, string? message = null) => new(true, data, message, null); + + public static Records Fail(string error, IReadOnlyList? errors = null) => new(false, default, error, errors); +} + +/// +/// Token-optimized standardized non-generic tool response wrapper for MCP tool execution. +/// +public record McpToolResult(bool Success, string Message, IReadOnlyList? Errors = null) +{ + public static McpToolResult Ok(string message = "Operation completed successfully.") => new(true, message); + + public static McpToolResult Fail(string error, IReadOnlyList? errors = null) => new(false, error, errors); +} + +public sealed record McpToolDefinition(string Name, string Title, string Description, string Group, bool ReadOnly, IReadOnlyList Parameters); + +public sealed record McpToolParameter(string Name, string Type, string Description, bool IsRequired, object? DefaultValue); + +public sealed record McpToolExecutionResult(bool Success, object? Data, string? Error, string? SerializedResult) +{ + public static McpToolExecutionResult Ok(object? data, JsonSerializerOptions? options = null) => + new(true, data, null, data != null ? JsonSerializer.Serialize(data, options ?? DefaultJsonOptions) : null); + + public static McpToolExecutionResult Fail(string error) => + new(false, null, error, null); + + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; +} + +public sealed record ToolMethodInfo(Type TargetType, MethodInfo Method, ParameterInfo[] Parameters); \ No newline at end of file diff --git a/PostFundManagement.Application/Mcp/UsersMcpTools.cs b/PostFundManagement.Application/Mcp/UsersMcpTools.cs new file mode 100644 index 0000000..2cfad51 --- /dev/null +++ b/PostFundManagement.Application/Mcp/UsersMcpTools.cs @@ -0,0 +1,66 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Application.Services.Users; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Models.Security; +using Role = PostFundManagement.Domain.Models.Security.Role; + +namespace PostFundManagement.Application.Mcp; + +/// +/// MCP tools proxying Identity & User Administration Services (RBAC Roles, Claims, Account Provisioning). +/// +[McpServerToolType] +public class UsersMcpTools(IUserService service) +{ + [McpServerTool(Name = "users_get_users", Title = "Get User Directory", ReadOnly = true)] + [Description("Retrieves a paginated and filtered list of user accounts across tenant partitions.")] + public async Task> GetUsersAsync([Description("User search and filtering parameters")] UserFilterRequest filter, [Description("Page index (1-based)")] int page = 1, [Description("Page size (1-100)")] int pageSize = 50, CancellationToken cancellationToken = default) => + (await service.GetUsersAsync(new Pagination(page, pageSize), filter, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_get_user", Title = "Get User by ID", ReadOnly = true)] + [Description("Retrieves account profile and security claims for a specific user.")] + public async Task> GetUserAsync([Description("Target user identifier")] Guid userId, CancellationToken cancellationToken = default) => + (await service.GetUserAsync(userId, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_create_user", Title = "Create User Account", Destructive = false)] + [Description("Provisions a new user account within a tenant organization.")] + public async Task CreateUserAsync([Description("Tenant hash identifier")] string tenantIdHash, [Description("Hashed ID number or passport")] string idNumberHash, [Description("User account creation parameters")] CreateUserRequest request, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.CreateUserAsync(tenantIdHash, idNumberHash, request, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_update_status", Title = "Update User Account Status", Destructive = false)] + [Description("Updates active, suspended, or archived state of a user account.")] + public async Task UpdateUserStatusAsync([Description("Target user identifier")] Guid userId, [Description("New activity status")] ActivityStatus status, CancellationToken cancellationToken = default) => + (await service.UpdateUserStatusAsync(userId, status, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_get_system_roles", Title = "Get System Roles", ReadOnly = true)] + [Description("Retrieves system-wide security and governance roles.")] + public async Task> GetSystemRolesAsync([Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.GetSystemRolesAsync(userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_get_user_roles", Title = "Get User Roles", ReadOnly = true)] + [Description("Retrieves assigned RBAC roles for a specific user.")] + public async Task> GetUserRolesAsync([Description("Target user identifier")] Guid targetUserId, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.GetUserRolesAsync(targetUserId, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_add_role", Title = "Assign Role to User", Destructive = false)] + [Description("Assigns a security role to a user account.")] + public async Task AddUserRoleAsync([Description("Target user identifier")] Guid targetUserId, [Description("Role identifier to assign")] Guid roleId, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.AddUserRoleAsync(targetUserId, roleId, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_delete_role", Title = "Revoke Role from User", Destructive = false)] + [Description("Revokes an assigned security role from a user account.")] + public async Task DeleteUserRoleAsync([Description("Target user identifier")] Guid targetUserId, [Description("Role identifier to revoke")] Guid roleId, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.DeleteUserRoleAsync(targetUserId, roleId, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_get_user_claims", Title = "Get User Claims", ReadOnly = true)] + [Description("Retrieves specific identity and authorization claims associated with a user.")] + public async Task> GetUserClaimsAsync([Description("Target user identifier")] Guid targetUserId, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.GetUserClaimsAsync(targetUserId, userId ?? SystemUser, cancellationToken)).ToMcpResult(); + + [McpServerTool(Name = "users_set_lockout", Title = "Set User Lockout", Destructive = false)] + [Description("Locks or unlocks a user account from authenticating into the platform.")] + public async Task SetUserLockoutAsync([Description("Target user identifier")] Guid targetUserId, [Description("True to lockout/disable account, false to re-enable")] bool disable, [Description("Optional acting user ID; defaults to System user if omitted")] Guid? userId = null, CancellationToken cancellationToken = default) => + (await service.SetUserLockoutAsync(targetUserId, userId ?? SystemUser, disable, cancellationToken)).ToMcpResult(); +} diff --git a/PostFundManagement.Application/PostFundManagement.Application.csproj b/PostFundManagement.Application/PostFundManagement.Application.csproj index c674f3c..8d9e719 100644 --- a/PostFundManagement.Application/PostFundManagement.Application.csproj +++ b/PostFundManagement.Application/PostFundManagement.Application.csproj @@ -12,6 +12,7 @@ + diff --git a/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs b/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs index d54448b..7c26ea0 100644 --- a/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs +++ b/PostFundManagement.Application/Services/Communications/CommunicationsCacheService.cs @@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Communications; diff --git a/PostFundManagement.Application/Services/Communications/CommunicationsService.cs b/PostFundManagement.Application/Services/Communications/CommunicationsService.cs index 956b360..0b92982 100644 --- a/PostFundManagement.Application/Services/Communications/CommunicationsService.cs +++ b/PostFundManagement.Application/Services/Communications/CommunicationsService.cs @@ -6,6 +6,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Communications; diff --git a/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs b/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs index cec7c02..fbad366 100644 --- a/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs +++ b/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs @@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Evidence; diff --git a/PostFundManagement.Application/Services/Evidence/EvidenceService.cs b/PostFundManagement.Application/Services/Evidence/EvidenceService.cs index 11331ef..f05f41f 100644 --- a/PostFundManagement.Application/Services/Evidence/EvidenceService.cs +++ b/PostFundManagement.Application/Services/Evidence/EvidenceService.cs @@ -6,6 +6,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Evidence; diff --git a/PostFundManagement.Application/Services/Execution/ExecutionCacheService.cs b/PostFundManagement.Application/Services/Execution/ExecutionCacheService.cs index 4a0ce3a..f087c71 100644 --- a/PostFundManagement.Application/Services/Execution/ExecutionCacheService.cs +++ b/PostFundManagement.Application/Services/Execution/ExecutionCacheService.cs @@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Execution; diff --git a/PostFundManagement.Application/Services/Execution/ExecutionService.cs b/PostFundManagement.Application/Services/Execution/ExecutionService.cs index b256359..9f82ea2 100644 --- a/PostFundManagement.Application/Services/Execution/ExecutionService.cs +++ b/PostFundManagement.Application/Services/Execution/ExecutionService.cs @@ -6,6 +6,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Execution; diff --git a/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs b/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs index e55dfa9..217df0d 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs @@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Governance; diff --git a/PostFundManagement.Application/Services/Governance/GovernanceService.cs b/PostFundManagement.Application/Services/Governance/GovernanceService.cs index 6ce6892..73663f8 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceService.cs @@ -7,6 +7,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Governance; diff --git a/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs b/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs index a3aba6d..5d0013d 100644 --- a/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs +++ b/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs @@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Grants; diff --git a/PostFundManagement.Application/Services/Grants/GrantsService.cs b/PostFundManagement.Application/Services/Grants/GrantsService.cs index 8ac1c93..8d16b34 100644 --- a/PostFundManagement.Application/Services/Grants/GrantsService.cs +++ b/PostFundManagement.Application/Services/Grants/GrantsService.cs @@ -6,6 +6,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Grants; diff --git a/PostFundManagement.Application/Services/Projects/ProjectCacheService.cs b/PostFundManagement.Application/Services/Projects/ProjectCacheService.cs index 1624d9e..0a1a013 100644 --- a/PostFundManagement.Application/Services/Projects/ProjectCacheService.cs +++ b/PostFundManagement.Application/Services/Projects/ProjectCacheService.cs @@ -3,6 +3,7 @@ using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Models; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Projects; diff --git a/PostFundManagement.Application/Services/Projects/ProjectService.cs b/PostFundManagement.Application/Services/Projects/ProjectService.cs index 0eb5ac4..6e7dfcf 100644 --- a/PostFundManagement.Application/Services/Projects/ProjectService.cs +++ b/PostFundManagement.Application/Services/Projects/ProjectService.cs @@ -6,6 +6,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Projects; diff --git a/PostFundManagement.Application/Services/Shared/AuditService.cs b/PostFundManagement.Application/Services/Shared/AuditService.cs index a6c9eb5..5b44d92 100644 --- a/PostFundManagement.Application/Services/Shared/AuditService.cs +++ b/PostFundManagement.Application/Services/Shared/AuditService.cs @@ -4,6 +4,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; @@ -22,6 +23,7 @@ public sealed class AuditService(IDbContextFactory context if (!tenantContextService.IsAdmin) { var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L; + query = query.Where(o => o.TenantId == currentTenantId); } @@ -34,15 +36,7 @@ public sealed class AuditService(IDbContextFactory context return Result.Ok(logs.Select(p => p.Map()).ToArray()); })); - public async Task LogActionAsync( - string entityId, - string entityName, - string action, - string[] changes, - Guid performedBy, - string? error = null, - long? tenantId = null, - CancellationToken cancellationToken = default) => + public async Task LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, string? error = null, long? tenantId = null, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); diff --git a/PostFundManagement.Application/Services/Shared/BrowserLocalStorageService.cs b/PostFundManagement.Application/Services/Shared/BrowserLocalStorageService.cs index 981e343..84f663b 100644 --- a/PostFundManagement.Application/Services/Shared/BrowserLocalStorageService.cs +++ b/PostFundManagement.Application/Services/Shared/BrowserLocalStorageService.cs @@ -1,4 +1,5 @@ using PostFundManagement.Domain.Errors; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; diff --git a/PostFundManagement.Application/Services/Shared/EmailService.cs b/PostFundManagement.Application/Services/Shared/EmailService.cs index fb5badd..94b7b58 100644 --- a/PostFundManagement.Application/Services/Shared/EmailService.cs +++ b/PostFundManagement.Application/Services/Shared/EmailService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Domain; using PostFundManagement.Domain.Configuration.Email; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models.Email; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; diff --git a/PostFundManagement.Application/Services/Shared/HashService.cs b/PostFundManagement.Application/Services/Shared/HashService.cs index 7c4309d..d7bb10c 100644 --- a/PostFundManagement.Application/Services/Shared/HashService.cs +++ b/PostFundManagement.Application/Services/Shared/HashService.cs @@ -1,6 +1,7 @@ using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Errors; using PostFundManagement.Application.Extensions; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; diff --git a/PostFundManagement.Application/Services/Shared/IdentityCacheService.cs b/PostFundManagement.Application/Services/Shared/IdentityCacheService.cs index 8af5dfd..71e4190 100644 --- a/PostFundManagement.Application/Services/Shared/IdentityCacheService.cs +++ b/PostFundManagement.Application/Services/Shared/IdentityCacheService.cs @@ -1,4 +1,5 @@ using PostFundManagement.Application.Abstractions.Services; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; diff --git a/PostFundManagement.Application/Services/Shared/IdentityService.cs b/PostFundManagement.Application/Services/Shared/IdentityService.cs index 076a3b9..41746f2 100644 --- a/PostFundManagement.Application/Services/Shared/IdentityService.cs +++ b/PostFundManagement.Application/Services/Shared/IdentityService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Domain; using PostFundManagement.Domain.Errors; using PostFundManagement.Infrastructure.Database; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; diff --git a/PostFundManagement.Application/Services/Shared/TokenService.cs b/PostFundManagement.Application/Services/Shared/TokenService.cs index cbdb524..9207800 100644 --- a/PostFundManagement.Application/Services/Shared/TokenService.cs +++ b/PostFundManagement.Application/Services/Shared/TokenService.cs @@ -2,6 +2,7 @@ using PostFundManagement.Domain.Configuration.Security; using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Models.Security; using PostFundManagement.Infrastructure.Security; +using Result = FluentResults.Result; namespace PostFundManagement.Application.Services.Shared; diff --git a/PostFundManagement.Application/Services/Users/UserCacheService.cs b/PostFundManagement.Application/Services/Users/UserCacheService.cs index f636c4e..f4c6c17 100644 --- a/PostFundManagement.Application/Services/Users/UserCacheService.cs +++ b/PostFundManagement.Application/Services/Users/UserCacheService.cs @@ -5,6 +5,8 @@ using PostFundManagement.Domain; using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models.Security; using Claim = PostFundManagement.Domain.Models.Security.Claim; +using Result = FluentResults.Result; +using Role = PostFundManagement.Domain.Models.Security.Role; namespace PostFundManagement.Application.Services.Users; diff --git a/PostFundManagement.Application/Services/Users/UserService.cs b/PostFundManagement.Application/Services/Users/UserService.cs index f52b1eb..929aafb 100644 --- a/PostFundManagement.Application/Services/Users/UserService.cs +++ b/PostFundManagement.Application/Services/Users/UserService.cs @@ -8,6 +8,8 @@ using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models.Security; using PostFundManagement.Infrastructure.Database; using Claim = PostFundManagement.Domain.Models.Security.Claim; +using Result = FluentResults.Result; +using Role = PostFundManagement.Domain.Models.Security.Role; namespace PostFundManagement.Application.Services.Users; diff --git a/PostFundManagement.Mcp/Dockerfile b/PostFundManagement.Mcp/Dockerfile new file mode 100644 index 0000000..ed0a066 --- /dev/null +++ b/PostFundManagement.Mcp/Dockerfile @@ -0,0 +1,41 @@ +# Stage 1: Runtime base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER app +WORKDIR /app +EXPOSE 8080 + +# Stage 2: Build & Restore +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Copy props and csproj files for layer caching +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Packages.props", "./"] +COPY ["PostFundManagement.snk", "./"] +COPY ["LICENSE", "./"] +COPY ["PostFundManagement.Mcp/PostFundManagement.Mcp.csproj", "PostFundManagement.Mcp/"] +COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"] +COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"] +COPY ["PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj", "PostFundManagement.Infrastructure/"] + +# Restore packages +RUN dotnet restore "PostFundManagement.Mcp/PostFundManagement.Mcp.csproj" + +# Copy full source code +COPY . . + +# Build from the project folder using relative filename +WORKDIR "/src/PostFundManagement.Mcp" +RUN dotnet build "PostFundManagement.Mcp.csproj" -c Release -o /app/build + +# Stage 3: Publish +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "PostFundManagement.Mcp.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Stage 4: Final Runtime +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PostFundManagement.Mcp.dll"] + diff --git a/PostFundManagement.Mcp/PostFundManagement.Mcp.csproj b/PostFundManagement.Mcp/PostFundManagement.Mcp.csproj new file mode 100644 index 0000000..726e495 --- /dev/null +++ b/PostFundManagement.Mcp/PostFundManagement.Mcp.csproj @@ -0,0 +1,38 @@ + + + + dotnet-PostFundManagement.Mcp-38a42b10-67c2-4889-b1d5-89f5cda11234 + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/PostFundManagement.Mcp/Program.cs b/PostFundManagement.Mcp/Program.cs new file mode 100644 index 0000000..9d768f9 --- /dev/null +++ b/PostFundManagement.Mcp/Program.cs @@ -0,0 +1,125 @@ +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Health; +using PostFundManagement.Domain.Mediator; +using PostFundManagement.Infrastructure.Database.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddFeatureManagement(); +builder.Services.AddSharedServices(); +builder.Services.AddRegisterServicesWithCaching(builder.Configuration); +builder.Services.AddEndpointAndServiceCaching(builder.Configuration); +builder.Services.AddTransient(); +builder.Services.AddSkorubaApiService(builder.Configuration); +builder.Services.AddHttpContextAccessor(); + +builder.Services.AddMediator(); +builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>)); +builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>)); + +builder.Services.AddEmailServices(builder.Configuration); +builder.Services.AddHashServices(builder.Configuration); +builder.Services.AddGarageS3(builder.Configuration); +builder.Services.AddHttpClient(); +builder.Services.AddDataProtectionDatabase(builder.Configuration); +builder.Services.AddApplicationDbContext(builder.Configuration); + +// Register application MCP tooling +builder.Services.AddApplicationMcpTools(); + +var isStdioMode = args.Contains("--stdio", StringComparer.OrdinalIgnoreCase); + +if (isStdioMode) +{ + // Local CLI / Stdio transport mode (e.g. for Claude Desktop, local cursor, or command-line agents) + builder.Services.AddMcpServer() + .WithApplicationMcpTools() + .WithStdioServerTransport(); +} +else +{ + // Web / SSE HTTP transport mode (for network agents, external LLM platforms, and remote orchestrators) + builder.Services.AddMcpServer() + .WithApplicationMcpTools() + .WithHttpTransport(); +} + +builder.Services.AddHealthChecks() + .AddCheck("PostgresDatabase") + .AddCheck("Self", () => HealthCheckResult.Healthy()); + +var allowedOrigins = builder.Configuration.GetSection("CorsOrigins").Get() ?? []; + +builder.Services.AddCors(options => +{ + options.AddPolicy(CorsPolicy, policy => + { + policy.SetIsOriginAllowed(origin => + { + if (string.IsNullOrWhiteSpace(origin)) return false; + + var clean = origin.TrimEnd('/'); + if (allowedOrigins.Any(a => string.Equals(a.TrimEnd('/'), clean, StringComparison.OrdinalIgnoreCase))) + return true; + + try + { + var host = new Uri(origin).Host; + if (host.EndsWith("khongisa.co.za", StringComparison.OrdinalIgnoreCase) || + host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + catch { /* ignore */ } + + return false; + }) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); + +var app = builder.Build(); + +if (!isStdioMode) +{ + app.UseCors(CorsPolicy); + app.UseRouting(); + + // Map official MCP protocol endpoints + app.MapMcp("/mcp"); + + // Root status info endpoint + app.MapGet("/", () => Results.Ok(new + { + Service = "PostFundManagement.Mcp", + Status = "Online", + Version = "2.3.0", + Protocol = "ModelContextProtocol", + McpEndpoint = "/mcp", + ToolGroupings = new[] + { + "Grants", + "Projects", + "Execution", + "Governance", + "Evidence", + "Communications", + "Users" + } + })); + + app.UseHealthChecks("/health", new HealthCheckOptions + { + Predicate = _ => true, + AllowCachingResponses = true, + ResponseWriter = HealthChecks.UI.Client.UIResponseWriter.WriteHealthCheckUIResponse + }); +} + +await app.RunAsync(); + diff --git a/PostFundManagement.Mcp/Properties/launchSettings.json b/PostFundManagement.Mcp/Properties/launchSettings.json new file mode 100644 index 0000000..3f26185 --- /dev/null +++ b/PostFundManagement.Mcp/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "PostFundManagement.Mcp": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:50668;http://localhost:50669" + } + } +} \ No newline at end of file diff --git a/PostFundManagement.Mcp/appsettings.json b/PostFundManagement.Mcp/appsettings.json new file mode 100644 index 0000000..e29defd --- /dev/null +++ b/PostFundManagement.Mcp/appsettings.json @@ -0,0 +1,42 @@ +{ + "EnableServiceCaching": true, + "CorsOrigins": [ + "https://partner.ifri.khongisa.co.za", + "https://console.ifri.khongisa.co.za", + "https://localhost:7208", + "http://localhost:5173", + "http://localhost:5174" + ], + "GeneralS3Settings": { + "ServiceUrl": "https://s3.garage.khongisa.co.za", + "Region": "garage", + "BucketName": "pfm", + "CdnBaseUrl": "https://pfm.cdn.khongisa.co.za" + }, + "Monitoring": { + "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", + "ServiceName": "Pfm.MCP" + }, + "SecuritySettings": { + "Authority": "https://sts.security.khongisa.co.za", + "Audience": "pfm-mcp-dev" + }, + "MachineIdentitySettings": { + "Authority": "https://sts.security.khongisa.co.za", + "Scope": "skoruba_identity_admin_api" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "ModelContextProtocol": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database": "Error", + "Microsoft.EntityFrameworkCore.Update": "Error" + } + }, + "HasherSettings": { + "MinHashLength": 11 + }, + "AllowedHosts": "*" +} + diff --git a/PostFundManagement.Tests/McpToolingTests.cs b/PostFundManagement.Tests/McpToolingTests.cs new file mode 100644 index 0000000..0e887fb --- /dev/null +++ b/PostFundManagement.Tests/McpToolingTests.cs @@ -0,0 +1,330 @@ +using Microsoft.Extensions.Logging.Abstractions; +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Services.Governance; +using PostFundManagement.Domain; +using PostFundManagement.Domain.Models; +using PostFundManagement.Domain.Models.Security; +using PostFundManagement.Application.Mcp; +using PostFundManagement.Application.Abstractions.Mcp; +using PostFundManagement.Application.Extensions; + +namespace PostFundManagement.Tests; + +public class McpToolingTests +{ + private readonly IServiceProvider _serviceProvider; + + public McpToolingTests() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddLogging(); + + // Register dummy/mock services for testing MCP proxying + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register MCP tooling & InApp client + services.AddApplicationMcpTools(); + + _serviceProvider = services.BuildServiceProvider(); + } + + [Fact] + public void AddApplicationMcpTools_RegistersAllToolTypesAndInAppClient() + { + using var scope = _serviceProvider.CreateScope(); + var sp = scope.ServiceProvider; + + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + Assert.NotNull(sp.GetService()); + } + + [Fact] + public void InAppMcpClient_DiscoversToolsAcrossAllServiceGroupings() + { + using var scope = _serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + var tools = client.GetAvailableTools(); + + Assert.NotEmpty(tools); + Assert.True(tools.Count >= 40, $"Expected at least 40 tools, but found {tools.Count}"); + + var groups = tools.Select(t => t.Group).Distinct().OrderBy(g => g).ToList(); + + Assert.Contains("Grants", groups); + Assert.Contains("Projects", groups); + Assert.Contains("Execution", groups); + Assert.Contains("Governance", groups); + Assert.Contains("Evidence", groups); + Assert.Contains("Communications", groups); + Assert.Contains("Users", groups); + } + + [Fact] + public async Task InAppMcpClient_ExecutesToolInProcess_WithoutNetworkHops() + { + using var scope = _serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + var result = await client.ExecuteToolAsync("grants_get_award", new Dictionary + { + ["awardIdHash"] = "awd_test_123" + }); + + Assert.True(result.Success, $"Execution failed: {result.Error}"); + Assert.NotNull(result.Data); + Assert.NotNull(result.SerializedResult); + + var mcpResult = result.Data as Records; + Assert.NotNull(mcpResult); + Assert.True(mcpResult.Success); + Assert.Equal(1L, mcpResult.Data?.Id); + Assert.Equal(1_000_000m, mcpResult.Data?.Amount); + } + + [Fact] + public async Task InAppMcpClient_WhenUnknownTool_ReturnsFailure() + { + using var scope = _serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetRequiredService(); + + var result = await client.ExecuteToolAsync("non_existent_tool"); + + Assert.False(result.Success); + Assert.Contains("not recognized", result.Error); + } + + [Fact] + public void McpToolResult_OptimizesTokenUsage_ForSuccessAndFailure() + { + var okResult = Records.Ok("TestData"); + Assert.True(okResult.Success); + Assert.Equal("TestData", okResult.Data); + Assert.Null(okResult.Errors); + + var failResult = Records.Fail("Something went wrong", ["Error detail 1"]); + Assert.False(failResult.Success); + Assert.Equal("Something went wrong", failResult.Message); + Assert.NotNull(failResult.Errors); + Assert.Single(failResult.Errors); + } + + #region Test Service Implementations + + private sealed class TestGrantsService : IGrantsService + { + public Task> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) + { + var award = new Award + { + Id = 1, + Amount = 1_000_000m + }; + return Task.FromResult(Result.Ok(award)); + } + + public Task> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok(Array.Empty())); + + public Task CreateAwardAsync(Application.Services.Grants.CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task UpdateAwardAsync(Application.Services.Grants.UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok(new Contract { Id = 1 })); + + public Task> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok(Array.Empty())); + + public Task CreateContractAsync(Application.Services.Grants.CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task UpdateContractAsync(Application.Services.Grants.UpdateContractRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task ExecuteContractAsync(Application.Services.Grants.ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok("https://test.cdn/contract.pdf")); + + public Task> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok(new Budget { Id = 1 })); + + public Task> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok(Array.Empty())); + + public Task CreateBudgetAsync(Application.Services.Grants.CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task UpdateBudgetAsync(Application.Services.Grants.UpdateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + + public Task UploadContractAsync(string contractIdHash, Guid userId, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken = default) => + Task.FromResult(Result.Ok()); + } + + private sealed class TestProjectService : IProjectService + { + public Task CreateActivityAsync(Application.Services.Projects.CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateBeneficiaryAsync(Application.Services.Projects.CreateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateChangeRequestAsync(Application.Services.Projects.CreateChangeRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateComplianceItemAsync(Application.Services.Projects.CreateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateIssueAsync(Application.Services.Projects.CreateIssueRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateOutcomeAsync(Application.Services.Projects.CreateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateProgrammeAsync(Application.Services.Projects.CreateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateProjectAsync(Application.Services.Projects.CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateSiteVisitAsync(Application.Services.Projects.CreateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetActivityAsync(string activityIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Domain.Models.Activity())); + public Task> GetBeneficiariesAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetBeneficiaryAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Beneficiary())); + public Task> GetChangeRequestsAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetChangeRequestAsync(string changeRequestIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new ChangeRequest())); + public Task> GetComplianceItemsAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetComplianceItemAsync(string complianceItemIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new ComplianceItem())); + public Task> GetIssuesAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetIssueAsync(string issueIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Issue())); + public Task> GetMilestoneAsync(string milestoneIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Milestone())); + public Task> GetMilestonesAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Outcome())); + public Task> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Programme())); + public Task> GetProgrammesAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProgrammeProjectsAsync(string programmeIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Project())); + public Task> GetProjectIndicatorsAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProjectMilestonesAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetSiteVisitsAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetSiteVisitAsync(string siteVisitIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new SiteVisit())); + public Task UpdateActivityAsync(Application.Services.Projects.UpdateActivityRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateBeneficiaryAsync(Application.Services.Projects.UpdateBeneficiaryRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateChangeRequestAsync(Application.Services.Projects.UpdateChangeRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateComplianceItemAsync(Application.Services.Projects.UpdateComplianceItemRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateIssueAsync(Application.Services.Projects.UpdateIssueRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateMilestoneAsync(Application.Services.Projects.UpdateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateOutcomeAsync(Application.Services.Projects.UpdateOutcomeRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateProgrammeAsync(Application.Services.Projects.UpdateProgrammeRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateProjectAsync(Application.Services.Projects.UpdateProjectRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateProjectStatusAsync(Application.Services.Projects.UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateSiteVisitAsync(Application.Services.Projects.UpdateSiteVisitRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + } + + private sealed class TestExecutionService : IExecutionService + { + public Task ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateDisbursementAsync(Application.Services.Execution.CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateRiskAsync(Application.Services.Execution.CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateRuleAsync(Application.Services.Execution.CreateRuleRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> GetDisbursementAsync(string disbursementIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Disbursement())); + public Task> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetRiskAsync(string riskIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Risk())); + public Task> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetRuleAsync(string ruleIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Rule())); + public Task> GetRulesAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task UpdateDisbursementAsync(Application.Services.Execution.UpdateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateRiskAsync(Application.Services.Execution.UpdateRiskRequest request, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateRuleAsync(Application.Services.Execution.UpdateRuleRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + } + + private sealed class TestGovernanceService : IGovernanceService + { + public Task CreateInstrumentAsync(Application.Services.Governance.CreateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateOrganisationAsync(Application.Services.Governance.CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreatePortfolioAsync(Application.Services.Governance.CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateTenantAsync(Application.Services.Governance.CreateTenantRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateTenantOrganisationAsync(string organisationIdHash, string tenantIdHash, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> GetDashboardAsync(CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Dashboard(0, 0, 0, 0, 0, 0))); + public Task> GetInstrumentsAsync(string programmeIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetOrganisationAsync(string organisationIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Organisation())); + public Task> GetOrganisationPorfoliosAsync(string organisationIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetPortfolioAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Portfolio())); + public Task> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetPorfolioValueAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(0m)); + public Task> GetPorfoliosAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProgrammeValueAsync(string programmeIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(0m)); + public Task> GetTenantAsync(string tenantIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Tenant())); + public Task> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new TenantOrganisation())); + public Task> GetTenantOrganisationsAsync(string tenantIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetTenantsAsync(CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task UpdateInstrumentAsync(Application.Services.Governance.UpdateInstrumentRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> UpdateOrganisationAsync(Application.Services.Governance.UpdateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Organisation())); + public Task UpdatePortfolioAsync(Application.Services.Governance.UpdatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateTenantAsync(Application.Services.Governance.UpdateTenantRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateTenantOrganisationAsync(string tenantOrganisationIdHash, ActivityStatus status, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateTenantUserAsync(Application.Services.Governance.UpdateTenantUserRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + } + + private sealed class TestEvidenceService : IEvidenceService + { + public Task CreateEvidenceAsync(Guid userId, Microsoft.AspNetCore.Http.IFormCollection form, Microsoft.AspNetCore.Http.IFormFile file, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateIndicatorAsync(Application.Services.Evidence.CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateMilestoneAsync(Application.Services.Evidence.CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> GetEvidenceAsync(string evidenceId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Domain.Models.Evidence())); + public Task> GetEvidenceFilesAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok("https://test.cdn/evidence.pdf")); + public Task> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken) => Task.FromResult(Result.Ok(new Indicator())); + public Task> GetIndicatorsAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetMilestoneEvidencesAsync(string milestoneIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, decimal? actualCost, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateEvidenceAsync(Application.Services.Evidence.UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + } + + private sealed class TestCommunicationsService : ICommunicationsService + { + public Task CreateNotificationAsync(Application.Services.Communications.CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> GetApplicantDataAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetApplicantInviteAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Invite())); + public Task> GetApplicantInvitesAsync(Guid applicantUserId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetInvitesAsync(Pagination pagination, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetNotificationAsync(string notificationIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Notification())); + public Task> GetNotificationsAsync(string organisationIdHash, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetUserNotificationsAsync(Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> SendInviteAsync(Application.Services.Communications.InviteRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new Invite())); + public Task UpdateNotificationAsync(Application.Services.Communications.UpdateNotificationRequest request, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + } + + private sealed class TestUserService : IUserService + { + public Task AddUserClaimAsync(AddUserClaimRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task AddUserRoleAsync(Guid targetUserId, Guid roleId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task CreateUserAsync(string tenantIdHash, string idNumberHash, CreateUserRequest request, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task DeleteUserClaimAsync(string claimIdHash, Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task DeleteUserRoleAsync(Guid targetUserId, Guid roleId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task> GetRoleClaimsAsync(Guid roleId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetSystemRolesAsync(Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(new User { Id = userId })); + public Task> GetUserClaimsAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetUserRolesAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task> GetUsersAsync(Pagination pagination, Application.Services.Users.UserFilterRequest request, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok(Array.Empty())); + public Task LogoutUserAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task SetUserLockoutAsync(Guid targetUserId, Guid userId, bool disable, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task SyncUserAsync(Guid userId, string email, string role, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + public Task UpdateUserStatusAsync(Guid userId, ActivityStatus status, CancellationToken cancellationToken = default) => Task.FromResult(Result.Ok()); + } + + #endregion +} diff --git a/PostFundManagement.slnx b/PostFundManagement.slnx index 6e5173b..eb919a4 100644 --- a/PostFundManagement.slnx +++ b/PostFundManagement.slnx @@ -1,13 +1,4 @@ - - - - - - - - - @@ -18,6 +9,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index bf112f1..eb30eb8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,14 @@ The application follows a modular, hierarchical architecture where high-level fu ### **5. Notifications & Platform Services** * Multi-channel delivery engine (`NotificationPlatform`) tracking candidate invitations, reminders, and status updates (`NotificationStatus`) across Email, SMS, WhatsApp, In-App, and Webhooks. -### **6. Background Processing & Distributed Jobs Engine** +### **6. Model Context Protocol (MCP) — In-App & External Tooling** +* **Central Tool Definitions (`PostFundManagement.Application/Mcp`)**: Tools proxy core domain services organized by business domain (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). +* **In-App MCP (`PostFundManagement.Api`)**: The API has direct in-process access to tooling via `IInAppMcpClient` without making external web or HTTP calls. +* **External MCP Server (`PostFundManagement.Mcp`)**: Dedicated .NET 10 host serving MCP tools over HTTP/SSE (`/mcp`) and Stdio (`--stdio`) for external agents, Claude Desktop, and IDEs. +* **Token Optimization**: Standardized `McpToolResult` models minimize prompt token overhead. +* **Agent Status**: Tooling infrastructure is established; agent definitions are deferred to future milestones. + +### **7. Background Processing & Distributed Jobs Engine** * **`PostFundManagement.Jobs`**: Dedicated background execution host powered by **Quartz.NET (v4.6+)** with a PostgreSQL clustered JobStore (`pfm-scheduler`). * **Decoupled Architecture**: `PostFundManagement.Api` serves strictly as a scheduler client (`ZeroSizeThreadPool`), dispatching events into the cluster while leaving execution exclusively to `PostFundManagement.Jobs` (`MaxConcurrency = 5`). * **Event-Driven Execution**: Domain events implementing `IEvent : INotification` are mediated and serialized into `MediatorJob`. diff --git a/docker-compose.yml b/docker-compose.yml index 9a243fb..c266692 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -123,6 +123,63 @@ services: # Redis overrides - ConnectionStrings__Redis=${REDIS_CONNECTION_STRING:-192.168.1.171:6379,ssl=false,abortConnect=false} restart: unless-stopped + pfm-mcp: + image: pfm-mcp + build: + context: . + dockerfile: ./PostFundManagement.Mcp/Dockerfile + network: host + container_name: pfm-mcp + ports: + - "8079:8080" + environment: + # Header forwarding overrides + - ASPNETCORE_ENVIRONMENT=${ENVIRONMENT:-Development} + - ForwardedHeadersConfiguration__Enabled=true + - ForwardedHeadersConfiguration__AllowAll=true + - ForwardedHeadersConfiguration__ForwardLimit=1 + + # CORS Overrides + - CorsOrigins__0=${CORS_ORIGIN_CONSOLE:-https://console.ifri.khongisa.co.za} + - CorsOrigins__1=${CORS_ORIGIN_API:-https://api.ifri.khongisa.co.za} + - CorsOrigins__2=${CORS_ORIGIN_LOCAL:-https://localhost:7208} + - CorsOrigins__3=${CORS_ORIGIN_PARTNER:-https://partner.ifri.khongisa.co.za} + - CorsOrigins__4=${CORS_ORIGIN_SHOWCASE:-https://showcase.ifri.khongisa.co.za} + + # Postgres Database Overrides + - ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD:-}; + + # Garage S3 Settings + - GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za} + - GeneralS3Settings__Region=${GENERAL_S3_REGION:-garage} + - GeneralS3Settings__BucketName=${GENERAL_S3_BUCKET_NAME:-pfm} + - GeneralS3Settings__CdnBaseUrl=${GENERAL_S3_CDN_BASE_URL:-https://pfm.cdn.khongisa.co.za} + - GeneralS3Settings__AccessKey=${GENERAL_S3_ACCESS_KEY:-} + - GeneralS3Settings__SecretKey=${GENERAL_S3_SECRET_KEY:-} + + # HashIds overrides + - HasherSettings__MinHashLength=${HASHER_MIN_HASH_LENGTH:-11} + - HasherSettings__Salt=${HASHER_SALT:-} + + # Skoruba Duende Administration Security Overrides + - ConnectionStrings__SkorubaApi=${SKORUBA_ADMIN_API:-https://api.security.khongisa.co.za/} + - MachineIdentitySettings__Authority=${ADMIN_SECURITY_AUTHORITY:-https://sts.security.khongisa.co.za} + - MachineIdentitySettings__Scope=${ADMIN_SECURITY_SCOPE:-} + - MachineIdentitySettings__ClientId=${ADMIN_SECURITY_CLIENT_ID:-} + - MachineIdentitySettings__ClientSecret=${ADMIN_SECURITY_CLIENT_SECRET:-} + + # Data Protection Overrides + - DataProtection__Certificate=${DATA_PROTECTION_CERTIFICATE:-} + - DataProtection__Password=${DATA_PROTECTION_CERTIFICATE_PASSWORD:-} + + # Monitoring Overrides + - Monitoring__Address=${MONITORING_ADDRESS:-http://aspire-dashboard-service.aspire.svc.cluster.local:18889} + - Monitoring__ServiceName=${MONITORING_SERVICE_NAME:-Pfm.MCP} + - Monitoring__ApiKey=${MONITORING_API_KEY:-} + + # Redis overrides + - ConnectionStrings__Redis=${REDIS_CONNECTION_STRING:-192.168.1.171:6379,ssl=false,abortConnect=false} + restart: unless-stopped pfm-console: image: pfm-console build: