Compare commits
5 Commits
34a379750f
...
8c51fa392b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c51fa392b | ||
|
|
0951c53827 | ||
|
|
9f774b56b0 | ||
|
|
a55006b810 | ||
|
|
b38a9d16fa |
@@ -66,11 +66,13 @@ public static class ModuleExtensions
|
||||
services.AddScoped<ColaFlow.Modules.ProjectManagement.Application.Services.IProjectPermissionService,
|
||||
ColaFlow.Modules.ProjectManagement.Infrastructure.Services.ProjectPermissionService>();
|
||||
|
||||
// Register MediatR handlers from Application assembly (v13.x syntax)
|
||||
// Register MediatR handlers from Application assemblies (v13.x syntax)
|
||||
// Consolidate all module handler registrations here to avoid duplicate registrations
|
||||
services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.LicenseKey = configuration["MediatR:LicenseKey"];
|
||||
cfg.RegisterServicesFromAssembly(typeof(CreateProjectCommand).Assembly);
|
||||
cfg.RegisterServicesFromAssembly(typeof(ColaFlow.Modules.Mcp.Application.EventHandlers.PendingChangeApprovedEventHandler).Assembly);
|
||||
});
|
||||
|
||||
// Register FluentValidation validators
|
||||
|
||||
@@ -128,7 +128,8 @@ builder.Services.AddAuthentication(options =>
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
})
|
||||
.AddMcpApiKeyAuthentication(); // Add MCP API Key authentication scheme
|
||||
|
||||
// Configure Authorization Policies for RBAC
|
||||
builder.Services.AddAuthorization(options =>
|
||||
@@ -153,6 +154,11 @@ builder.Services.AddAuthorization(options =>
|
||||
// AI Agent only (for MCP integration testing)
|
||||
options.AddPolicy("RequireAIAgent", policy =>
|
||||
policy.RequireRole("AIAgent"));
|
||||
|
||||
// MCP API Key authentication policy (for /mcp-sdk endpoint)
|
||||
options.AddPolicy("RequireMcpApiKey", policy =>
|
||||
policy.AddAuthenticationSchemes(ColaFlow.Modules.Mcp.Infrastructure.Authentication.McpApiKeyAuthenticationOptions.DefaultScheme)
|
||||
.RequireAuthenticatedUser());
|
||||
});
|
||||
|
||||
// Configure CORS for frontend (SignalR requires AllowCredentials)
|
||||
@@ -238,9 +244,10 @@ app.MapHub<NotificationHub>("/hubs/notification");
|
||||
app.MapHub<McpNotificationHub>("/hubs/mcp-notifications");
|
||||
|
||||
// ============================================
|
||||
// Map MCP SDK Endpoint
|
||||
// Map MCP SDK Endpoint with API Key Authentication
|
||||
// ============================================
|
||||
app.MapMcp("/mcp-sdk"); // Official SDK endpoint at /mcp-sdk
|
||||
app.MapMcp("/mcp-sdk")
|
||||
.RequireAuthorization("RequireMcpApiKey"); // Require MCP API Key authentication
|
||||
// Note: Legacy /mcp endpoint still handled by UseMcpMiddleware() above
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace ColaFlow.Modules.Mcp.Application.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for the 'resources/read' MCP method
|
||||
/// Uses scoped IMcpResource instances from DI to avoid DbContext disposal issues
|
||||
/// </summary>
|
||||
public class ResourcesReadMethodHandler(
|
||||
ILogger<ResourcesReadMethodHandler> logger,
|
||||
IMcpResourceRegistry resourceRegistry)
|
||||
IMcpResourceRegistry resourceRegistry,
|
||||
IEnumerable<IMcpResource> scopedResources)
|
||||
: IMcpMethodHandler
|
||||
{
|
||||
public string MethodName => "resources/read";
|
||||
@@ -32,13 +34,20 @@ public class ResourcesReadMethodHandler(
|
||||
|
||||
logger.LogInformation("Reading resource: {Uri}", request.Uri);
|
||||
|
||||
// Find resource by URI
|
||||
var resource = resourceRegistry.GetResourceByUri(request.Uri);
|
||||
if (resource == null)
|
||||
// Find resource descriptor from registry (for URI template matching)
|
||||
var registryResource = resourceRegistry.GetResourceByUri(request.Uri);
|
||||
if (registryResource == null)
|
||||
{
|
||||
throw new McpNotFoundException($"Resource not found: {request.Uri}");
|
||||
}
|
||||
|
||||
// Get the scoped resource instance from DI (fresh DbContext)
|
||||
var resource = scopedResources.FirstOrDefault(r => r.Uri == registryResource.Uri);
|
||||
if (resource == null)
|
||||
{
|
||||
throw new McpNotFoundException($"Resource implementation not found: {registryResource.Uri}");
|
||||
}
|
||||
|
||||
// Parse URI and extract parameters
|
||||
var resourceRequest = ParseResourceRequest(request.Uri, resource.Uri);
|
||||
|
||||
@@ -114,6 +123,7 @@ public class ResourcesReadMethodHandler(
|
||||
|
||||
private class ResourceReadParams
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("uri")]
|
||||
public string Uri { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.ComponentModel;
|
||||
using System.Security.Claims;
|
||||
using ColaFlow.Modules.Mcp.Application.DTOs;
|
||||
using ColaFlow.Modules.Mcp.Application.Services;
|
||||
using ColaFlow.Modules.Mcp.Domain.Exceptions;
|
||||
using ColaFlow.Modules.Mcp.Domain.Services;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Application.SdkTools;
|
||||
|
||||
/// <summary>
|
||||
/// MCP Tool: create_project (SDK-based implementation)
|
||||
/// Creates a new Project in ColaFlow
|
||||
/// Generates a Diff Preview and creates a PendingChange for approval
|
||||
/// </summary>
|
||||
[McpServerToolType]
|
||||
public class CreateProjectSdkTool
|
||||
{
|
||||
private readonly IPendingChangeService _pendingChangeService;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly DiffPreviewService _diffPreviewService;
|
||||
private readonly ILogger<CreateProjectSdkTool> _logger;
|
||||
|
||||
public CreateProjectSdkTool(
|
||||
IPendingChangeService pendingChangeService,
|
||||
IProjectRepository projectRepository,
|
||||
ITenantContext tenantContext,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
DiffPreviewService diffPreviewService,
|
||||
ILogger<CreateProjectSdkTool> logger)
|
||||
{
|
||||
_pendingChangeService = pendingChangeService ?? throw new ArgumentNullException(nameof(pendingChangeService));
|
||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
_tenantContext = tenantContext ?? throw new ArgumentNullException(nameof(tenantContext));
|
||||
_httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
|
||||
_diffPreviewService = diffPreviewService ?? throw new ArgumentNullException(nameof(diffPreviewService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
[McpServerTool]
|
||||
[Description("Create a new project in ColaFlow. Projects organize issues (Epics, Stories, Tasks, Bugs). Requires human approval before being created.")]
|
||||
public async Task<string> CreateProjectAsync(
|
||||
[Description("The name of the project (max 100 characters)")] string name,
|
||||
[Description("The project key (e.g., 'CFD', 'PRJ'). Must be 2-10 uppercase letters and unique within the tenant.")] string key,
|
||||
[Description("Detailed project description (optional, max 500 characters)")] string? description = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Executing create_project tool (SDK)");
|
||||
|
||||
// 1. Validate input
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new McpInvalidParamsException("Project name cannot be empty");
|
||||
|
||||
if (name.Length > 100)
|
||||
throw new McpInvalidParamsException("Project name cannot exceed 100 characters");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new McpInvalidParamsException("Project key cannot be empty");
|
||||
|
||||
if (key.Length < 2 || key.Length > 10)
|
||||
throw new McpInvalidParamsException("Project key must be between 2 and 10 characters");
|
||||
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(key, "^[A-Z]+$"))
|
||||
throw new McpInvalidParamsException("Project key must contain only uppercase letters");
|
||||
|
||||
if (description?.Length > 500)
|
||||
throw new McpInvalidParamsException("Project description cannot exceed 500 characters");
|
||||
|
||||
// 2. Check if project key already exists
|
||||
var existingProject = await _projectRepository.GetByKeyAsync(key, cancellationToken);
|
||||
if (existingProject != null)
|
||||
throw new McpInvalidParamsException($"Project with key '{key}' already exists");
|
||||
|
||||
// 3. Get Owner ID from HTTP context claims
|
||||
var ownerId = GetUserIdFromClaims();
|
||||
|
||||
// 4. Get Tenant ID from context
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// 5. Build "after data" object for diff preview
|
||||
var afterData = new
|
||||
{
|
||||
name = name,
|
||||
key = key,
|
||||
description = description ?? string.Empty,
|
||||
ownerId = ownerId,
|
||||
tenantId = tenantId,
|
||||
status = "Active"
|
||||
};
|
||||
|
||||
// 6. Generate Diff Preview (CREATE operation)
|
||||
var diff = _diffPreviewService.GenerateCreateDiff(
|
||||
entityType: "Project",
|
||||
afterEntity: afterData,
|
||||
entityKey: key // Use project key as the entity key
|
||||
);
|
||||
|
||||
// 7. Create PendingChange (do NOT execute yet)
|
||||
var pendingChange = await _pendingChangeService.CreateAsync(
|
||||
new CreatePendingChangeRequest
|
||||
{
|
||||
ToolName = "create_project",
|
||||
Diff = diff,
|
||||
ExpirationHours = 24
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"PendingChange created: {PendingChangeId} - CREATE Project: {Name} ({Key})",
|
||||
pendingChange.Id, name, key);
|
||||
|
||||
// 8. Return pendingChangeId to AI (NOT the created project)
|
||||
return $"Project creation request submitted for approval.\n\n" +
|
||||
$"**Pending Change ID**: {pendingChange.Id}\n" +
|
||||
$"**Status**: Pending Approval\n" +
|
||||
$"**Project Name**: {name}\n" +
|
||||
$"**Project Key**: {key}\n" +
|
||||
$"**Description**: {(string.IsNullOrEmpty(description) ? "(none)" : description)}\n\n" +
|
||||
$"A human user must approve this change before the project is created. " +
|
||||
$"The change will expire at {pendingChange.ExpiresAt:yyyy-MM-dd HH:mm} UTC if not approved.";
|
||||
}
|
||||
catch (McpException)
|
||||
{
|
||||
throw; // Re-throw MCP exceptions as-is
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error executing create_project tool (SDK)");
|
||||
throw new McpInvalidParamsException($"Error creating project: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private Guid GetUserIdFromClaims()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
throw new McpInvalidParamsException("HTTP context not available");
|
||||
|
||||
var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? httpContext.User.FindFirst("sub")?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim))
|
||||
{
|
||||
// Fallback: Try to get from API key context
|
||||
var apiKeyId = httpContext.Items["ApiKeyId"] as Guid?;
|
||||
if (apiKeyId.HasValue)
|
||||
{
|
||||
// Use API key ID as owner ID (for MCP API key authentication)
|
||||
return apiKeyId.Value;
|
||||
}
|
||||
throw new McpInvalidParamsException("User ID not found in authentication context");
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out var userId))
|
||||
throw new McpInvalidParamsException("Invalid user ID format in authentication context");
|
||||
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,9 @@ public class PendingChangeService(
|
||||
var tenantId = _tenantContext.GetCurrentTenantId();
|
||||
|
||||
// Get API Key ID from HttpContext (set by MCP authentication middleware)
|
||||
var apiKeyIdNullable = _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?;
|
||||
// Check both "McpApiKeyId" (from McpApiKeyAuthenticationHandler) and "ApiKeyId" (from legacy middleware)
|
||||
var apiKeyIdNullable = _httpContextAccessor.HttpContext?.Items["McpApiKeyId"] as Guid?
|
||||
?? _httpContextAccessor.HttpContext?.Items["ApiKeyId"] as Guid?;
|
||||
if (!apiKeyIdNullable.HasValue)
|
||||
{
|
||||
throw new McpUnauthorizedException("API Key not found in request context");
|
||||
|
||||
@@ -28,9 +28,9 @@ public sealed class ApiKeyPermissions
|
||||
public List<string> AllowedTools { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Private constructor for EF Core
|
||||
/// Parameterless constructor for EF Core and JSON deserialization
|
||||
/// </summary>
|
||||
private ApiKeyPermissions()
|
||||
public ApiKeyPermissions()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using ColaFlow.Modules.Mcp.Application.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Infrastructure.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication handler for MCP API Key authentication.
|
||||
/// This handler validates API keys in the Authorization header
|
||||
/// and creates claims for the authenticated user.
|
||||
/// </summary>
|
||||
public class McpApiKeyAuthenticationHandler : AuthenticationHandler<McpApiKeyAuthenticationOptions>
|
||||
{
|
||||
private readonly IMcpApiKeyService _apiKeyService;
|
||||
|
||||
public McpApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<McpApiKeyAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
IMcpApiKeyService apiKeyService)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_apiKeyService = apiKeyService;
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Extract API Key from Authorization header
|
||||
var apiKey = ExtractApiKey();
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return AuthenticateResult.Fail("Missing API Key. Please provide Authorization: Bearer <api_key> header.");
|
||||
}
|
||||
|
||||
// Get client IP address
|
||||
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
// Validate API Key
|
||||
var validationResult = await _apiKeyService.ValidateAsync(apiKey, ipAddress, Context.RequestAborted);
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
Logger.LogWarning("MCP SDK request rejected - Invalid API Key: {ErrorMessage}", validationResult.ErrorMessage);
|
||||
return AuthenticateResult.Fail(validationResult.ErrorMessage ?? "Invalid API Key");
|
||||
}
|
||||
|
||||
// Create claims from validation result
|
||||
// Note: Use "tenant_id" to match ITenantContext implementations that look for this claim name
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, validationResult.UserId.ToString()),
|
||||
new("ApiKeyId", validationResult.ApiKeyId.ToString()),
|
||||
new("tenant_id", validationResult.TenantId.ToString()),
|
||||
new("UserId", validationResult.UserId.ToString()),
|
||||
};
|
||||
|
||||
// Add permission claims
|
||||
if (validationResult.Permissions != null)
|
||||
{
|
||||
claims.Add(new Claim("McpPermissions:Read", validationResult.Permissions.Read.ToString()));
|
||||
claims.Add(new Claim("McpPermissions:Write", validationResult.Permissions.Write.ToString()));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
|
||||
// Store validation result in HttpContext.Items for downstream use
|
||||
Context.Items["McpAuthType"] = "ApiKey";
|
||||
Context.Items["McpApiKeyId"] = validationResult.ApiKeyId;
|
||||
Context.Items["McpTenantId"] = validationResult.TenantId;
|
||||
Context.Items["McpUserId"] = validationResult.UserId;
|
||||
Context.Items["McpPermissions"] = validationResult.Permissions;
|
||||
|
||||
Logger.LogDebug("MCP SDK request authenticated - ApiKey: {ApiKeyId}, Tenant: {TenantId}, User: {UserId}",
|
||||
validationResult.ApiKeyId, validationResult.TenantId, validationResult.UserId);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.StatusCode = 401;
|
||||
Response.ContentType = "application/json";
|
||||
|
||||
var errorResponse = new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
error = new
|
||||
{
|
||||
code = -32001, // Custom error code for authentication failure
|
||||
message = "Unauthorized",
|
||||
data = new { details = "Missing or invalid API Key. Please provide Authorization: Bearer <api_key> header." }
|
||||
},
|
||||
id = (object?)null
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(errorResponse, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
return Response.WriteAsync(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract API Key from Authorization header
|
||||
/// Supports: Authorization: Bearer <api_key>
|
||||
/// </summary>
|
||||
private string? ExtractApiKey()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var authHeaderValue = authHeader.ToString();
|
||||
if (string.IsNullOrWhiteSpace(authHeaderValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Support "Bearer <api_key>" format
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (authHeaderValue.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return authHeaderValue.Substring(bearerPrefix.Length).Trim();
|
||||
}
|
||||
|
||||
// Also support direct API key without "Bearer " prefix (for compatibility)
|
||||
return authHeaderValue.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for MCP API Key authentication
|
||||
/// </summary>
|
||||
public class McpApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The authentication scheme name
|
||||
/// </summary>
|
||||
public const string DefaultScheme = "McpApiKey";
|
||||
}
|
||||
@@ -3,11 +3,13 @@ using ColaFlow.Modules.Mcp.Application.Resources;
|
||||
using ColaFlow.Modules.Mcp.Application.Services;
|
||||
using ColaFlow.Modules.Mcp.Contracts.Resources;
|
||||
using ColaFlow.Modules.Mcp.Domain.Repositories;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.Authentication;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.BackgroundServices;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
|
||||
using ColaFlow.Modules.Mcp.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -76,9 +78,25 @@ public static class McpServiceExtensions
|
||||
services.AddScoped<IMcpMethodHandler, ToolsListMethodHandler>();
|
||||
services.AddScoped<IMcpMethodHandler, ToolsCallMethodHandler>();
|
||||
|
||||
// Note: MediatR handlers for MCP are registered in ModuleExtensions.cs AddProjectManagementModule()
|
||||
// They are included via RegisterServicesFromAssembly for the MCP Application assembly
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds MCP API Key authentication scheme to the authentication builder.
|
||||
/// This enables the /mcp-sdk endpoint to use API Key authentication.
|
||||
/// </summary>
|
||||
public static AuthenticationBuilder AddMcpApiKeyAuthentication(
|
||||
this AuthenticationBuilder builder,
|
||||
Action<McpApiKeyAuthenticationOptions>? configureOptions = null)
|
||||
{
|
||||
return builder.AddScheme<McpApiKeyAuthenticationOptions, McpApiKeyAuthenticationHandler>(
|
||||
McpApiKeyAuthenticationOptions.DefaultScheme,
|
||||
configureOptions ?? (_ => { }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds MCP middleware to the application pipeline
|
||||
/// IMPORTANT: Middleware order matters - must be in this sequence:
|
||||
|
||||
@@ -15,8 +15,8 @@ public class McpApiKeyAuthenticationMiddleware(
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, IMcpApiKeyService apiKeyService)
|
||||
{
|
||||
// Only apply to /mcp endpoints
|
||||
if (!context.Request.Path.StartsWithSegments("/mcp"))
|
||||
// Only apply to /mcp-sdk endpoint (not /api/mcp/* which uses JWT auth)
|
||||
if (!context.Request.Path.StartsWithSegments("/mcp-sdk"))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,13 @@ public class McpExceptionHandlerMiddleware(
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// Only handle exceptions for MCP endpoints
|
||||
if (!context.Request.Path.StartsWithSegments("/mcp-sdk"))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
|
||||
@@ -64,11 +64,12 @@ public class McpLoggingMiddleware(
|
||||
|
||||
/// <summary>
|
||||
/// Checks if this is an MCP request
|
||||
/// Only applies to /mcp-sdk endpoint (not /api/mcp/* which uses JWT auth)
|
||||
/// </summary>
|
||||
private static bool IsMcpRequest(HttpContext context)
|
||||
{
|
||||
return context.Request.Method == "POST"
|
||||
&& context.Request.Path.StartsWithSegments("/mcp");
|
||||
&& context.Request.Path.StartsWithSegments("/mcp-sdk");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -13,8 +13,8 @@ public class McpMiddleware(RequestDelegate next, ILogger<McpMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, IMcpProtocolHandler protocolHandler)
|
||||
{
|
||||
// Only handle POST requests to /mcp endpoint
|
||||
if (context.Request.Method != "POST" || !context.Request.Path.StartsWithSegments("/mcp"))
|
||||
// Only handle POST requests to /mcp-sdk endpoint (not /api/mcp/* which uses JWT auth)
|
||||
if (context.Request.Method != "POST" || !context.Request.Path.StartsWithSegments("/mcp-sdk"))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,12 @@ public interface ITenantContext
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown when tenant context is not available</exception>
|
||||
Guid GetCurrentTenantId();
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the current tenant ID without throwing exceptions
|
||||
/// </summary>
|
||||
/// <returns>The current tenant ID or null if not available</returns>
|
||||
Guid? TryGetCurrentTenantId();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current user ID from claims (optional - may be null for system operations)
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Aggregates.ProjectAggregate;
|
||||
@@ -10,8 +9,9 @@ namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Project Management Module DbContext
|
||||
/// Uses ITenantContext for centralized tenant resolution (supports both JWT and MCP API Key authentication)
|
||||
/// </summary>
|
||||
public class PMDbContext(DbContextOptions<PMDbContext> options, IHttpContextAccessor httpContextAccessor)
|
||||
public class PMDbContext(DbContextOptions<PMDbContext> options, ITenantContext tenantContext)
|
||||
: DbContext(options), IApplicationDbContext
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
@@ -53,16 +53,12 @@ public class PMDbContext(DbContextOptions<PMDbContext> options, IHttpContextAcce
|
||||
|
||||
private TenantId GetCurrentTenantId()
|
||||
{
|
||||
var tenantIdClaim = httpContextAccessor?.HttpContext?.User
|
||||
.FindFirst("tenant_id")?.Value;
|
||||
// Use centralized tenant resolution from ITenantContext
|
||||
// TryGetCurrentTenantId handles both JWT claims and MCP API Key authentication
|
||||
var tenantId = tenantContext.TryGetCurrentTenantId();
|
||||
|
||||
if (Guid.TryParse(tenantIdClaim, out var tenantId) && tenantId != Guid.Empty)
|
||||
{
|
||||
return TenantId.From(tenantId);
|
||||
}
|
||||
|
||||
// Return a dummy value for queries outside HTTP context (e.g., migrations)
|
||||
// Return dummy value for queries outside tenant context (e.g., migrations)
|
||||
// These will return no results due to the filter
|
||||
return TenantId.From(Guid.Empty);
|
||||
return TenantId.From(tenantId ?? Guid.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Persistence;
|
||||
|
||||
@@ -19,16 +19,18 @@ public class PMDbContextFactory : IDesignTimeDbContextFactory<PMDbContext>
|
||||
var optionsBuilder = new DbContextOptionsBuilder<PMDbContext>();
|
||||
optionsBuilder.UseNpgsql(connectionString, b => b.MigrationsAssembly("ColaFlow.Modules.ProjectManagement.Infrastructure"));
|
||||
|
||||
// Create DbContext with a mock HttpContextAccessor (for migrations only)
|
||||
return new PMDbContext(optionsBuilder.Options, new MockHttpContextAccessor());
|
||||
// Create DbContext with a mock TenantContext (for migrations only)
|
||||
return new PMDbContext(optionsBuilder.Options, new MockTenantContext());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock HttpContextAccessor for design-time operations
|
||||
/// Returns null HttpContext which PMDbContext handles gracefully
|
||||
/// Mock TenantContext for design-time operations
|
||||
/// Returns null/empty tenant ID which PMDbContext handles gracefully
|
||||
/// </summary>
|
||||
private class MockHttpContextAccessor : IHttpContextAccessor
|
||||
private class MockTenantContext : ITenantContext
|
||||
{
|
||||
public HttpContext? HttpContext { get; set; } = null;
|
||||
public Guid GetCurrentTenantId() => Guid.Empty;
|
||||
public Guid? TryGetCurrentTenantId() => null;
|
||||
public Guid? GetCurrentUserId() => null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,23 +5,40 @@ using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
namespace ColaFlow.Modules.ProjectManagement.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of ITenantContext that retrieves tenant ID from JWT claims
|
||||
/// Implementation of ITenantContext that retrieves tenant ID from JWT claims or MCP API Key context
|
||||
/// Supports both JWT authentication (claims) and MCP API Key authentication (HttpContext.Items)
|
||||
/// </summary>
|
||||
public sealed class TenantContext(IHttpContextAccessor httpContextAccessor) : ITenantContext
|
||||
{
|
||||
public Guid GetCurrentTenantId()
|
||||
{
|
||||
var tenantId = TryGetCurrentTenantId();
|
||||
if (tenantId == null)
|
||||
throw new UnauthorizedAccessException("Tenant ID not found in claims");
|
||||
|
||||
return tenantId.Value;
|
||||
}
|
||||
|
||||
public Guid? TryGetCurrentTenantId()
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
throw new InvalidOperationException("HTTP context is not available");
|
||||
return null;
|
||||
|
||||
// First, try to get from MCP API Key authentication (HttpContext.Items)
|
||||
if (httpContext.Items.TryGetValue("McpTenantId", out var mcpTenantIdObj) && mcpTenantIdObj is Guid mcpTenantId)
|
||||
{
|
||||
return mcpTenantId;
|
||||
}
|
||||
|
||||
// Fallback to JWT claims
|
||||
var user = httpContext.User;
|
||||
var tenantClaim = user.FindFirst("tenant_id") ?? user.FindFirst("tenantId");
|
||||
|
||||
if (tenantClaim == null || !Guid.TryParse(tenantClaim.Value, out var tenantId))
|
||||
throw new UnauthorizedAccessException("Tenant ID not found in claims");
|
||||
if (tenantClaim != null && Guid.TryParse(tenantClaim.Value, out var tenantId))
|
||||
return tenantId;
|
||||
|
||||
return tenantId;
|
||||
return null;
|
||||
}
|
||||
|
||||
public Guid? GetCurrentUserId()
|
||||
@@ -30,6 +47,13 @@ public sealed class TenantContext(IHttpContextAccessor httpContextAccessor) : IT
|
||||
if (httpContext == null)
|
||||
return null;
|
||||
|
||||
// First, try to get from MCP API Key authentication (HttpContext.Items)
|
||||
if (httpContext.Items.TryGetValue("McpUserId", out var mcpUserIdObj) && mcpUserIdObj is Guid mcpUserId)
|
||||
{
|
||||
return mcpUserId;
|
||||
}
|
||||
|
||||
// Fallback to JWT claims
|
||||
var user = httpContext.User;
|
||||
var userIdClaim = user.FindFirst(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirst("sub")
|
||||
|
||||
Reference in New Issue
Block a user