# MCP Authentication Architecture ## Table of Contents 1. [MCP Authentication Overview](#mcp-authentication-overview) 2. [McpToken Entity Design](#mcptoken-entity-design) 3. [MCP Token Format](#mcp-token-format) 4. [Permission Model Design](#permission-model-design) 5. [Token Generation Flow](#token-generation-flow) 6. [Token Validation Flow](#token-validation-flow) 7. [MCP Authentication Middleware](#mcp-authentication-middleware) 8. [Permission Enforcement](#permission-enforcement) 9. [Audit Logging](#audit-logging) 10. [Database Schema](#database-schema) 11. [Frontend Token Management UI](#frontend-token-management-ui) 12. [Security Considerations](#security-considerations) 13. [Testing](#testing) --- ## MCP Authentication Overview ### What is MCP (Model Context Protocol)? MCP is an open protocol that enables AI agents (like Claude, ChatGPT) to access external data sources and tools. ColaFlow implements an **MCP Server** that allows AI agents to: - Search projects, issues, and documents - Create and update tasks - Generate reports - Log decisions - Execute workflows ### Authentication Requirements AI agents need a secure way to authenticate and perform operations: 1. **Long-lived tokens**: AI agents use API tokens (not JWT) 2. **Fine-grained permissions**: Each token has specific resource/operation permissions 3. **Audit trail**: All MCP operations must be logged 4. **Tenant isolation**: Tokens are scoped to a single tenant 5. **Revocable**: Tokens can be revoked instantly ### Architecture Overview ```mermaid graph TB A[AI Agent - Claude/ChatGPT] --> B[MCP Client SDK] B --> C[HTTPS Request with Bearer Token] C --> D[ColaFlow MCP Server] D --> E{McpAuthenticationMiddleware} E --> F{Validate Token} F -->|Valid| G{Check Permissions} F -->|Invalid| H[401 Unauthorized] G -->|Allowed| I[Execute MCP Tool/Resource] G -->|Denied| J[403 Forbidden] I --> K[Audit Log] I --> L[Return Response] ``` ### Token Flow ``` ┌─────────────────────────────────────────────────────────────┐ │ User Actions (Web UI) │ │ 1. Navigate to Settings → MCP Tokens │ │ 2. Click "Generate Token" │ │ 3. Configure permissions (resources + operations) │ │ 4. Click "Create" │ └────────────────────────┬────────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────────┐ │ Backend API │ │ - CreateMcpTokenCommand │ │ - Generate: mcp__ │ │ - Hash token with SHA256 │ │ - Store in database │ └────────────────────────┬────────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────────┐ │ Frontend (One-time Display) │ │ - Show token in modal (copy button) │ │ - WARNING: "Save this token, it won't be shown again" │ └────────────────────────┬────────────────────────────────────┘ │ ┌────────────────────────▼────────────────────────────────────┐ │ AI Agent Configuration │ │ - User copies token │ │ - Configures AI agent environment variable │ │ - AI agent sends: Authorization: Bearer mcp_acme_xxx │ └──────────────────────────────────────────────────────────────┘ ``` --- ## McpToken Entity Design ### McpToken Aggregate Root **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/McpToken.cs` ```csharp using ColaFlow.Domain.Common; using ColaFlow.Domain.Aggregates.McpTokenAggregate.Events; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects; namespace ColaFlow.Domain.Aggregates.McpTokenAggregate; /// /// MCP Token aggregate root - represents an API token for AI agent authentication /// public sealed class McpToken : AggregateRoot { // Tenant association public TenantId TenantId { get; private set; } // User association (null for service accounts) public UserId? UserId { get; private set; } // Token details public TokenName Name { get; private set; } public string TokenHash { get; private set; } // SHA256 hash of the token public McpPermissionSet Permissions { get; private set; } // Status public TokenStatus Status { get; private set; } public DateTime CreatedAt { get; private set; } public DateTime? ExpiresAt { get; private set; } public DateTime? RevokedAt { get; private set; } public string? RevocationReason { get; private set; } // Usage tracking public DateTime? LastUsedAt { get; private set; } public int UsageCount { get; private set; } // Security public string? IpWhitelist { get; private set; } // JSON array of allowed IPs // Private constructor for EF Core private McpToken() { } // Factory method public static McpToken Create( TenantId tenantId, UserId? userId, TokenName name, string tokenHash, McpPermissionSet permissions, DateTime? expiresAt = null, string? ipWhitelist = null) { var token = new McpToken { Id = McpTokenId.CreateUnique(), TenantId = tenantId, UserId = userId, Name = name, TokenHash = tokenHash, Permissions = permissions, Status = TokenStatus.Active, CreatedAt = DateTime.UtcNow, ExpiresAt = expiresAt, IpWhitelist = ipWhitelist, UsageCount = 0 }; token.AddDomainEvent(new McpTokenCreatedEvent(token.Id, tenantId, name)); return token; } // Business methods public void Revoke(string reason) { if (Status == TokenStatus.Revoked) throw new InvalidOperationException("Token is already revoked"); Status = TokenStatus.Revoked; RevokedAt = DateTime.UtcNow; RevocationReason = reason; UpdatedAt = DateTime.UtcNow; AddDomainEvent(new McpTokenRevokedEvent(Id, reason)); } public void RecordUsage(string ipAddress) { if (Status != TokenStatus.Active) throw new InvalidOperationException("Cannot use inactive token"); if (ExpiresAt.HasValue && ExpiresAt.Value < DateTime.UtcNow) { Status = TokenStatus.Expired; throw new InvalidOperationException("Token has expired"); } // Validate IP whitelist if (!string.IsNullOrEmpty(IpWhitelist) && !IsIpAllowed(ipAddress)) { throw new UnauthorizedAccessException($"IP address {ipAddress} is not whitelisted"); } LastUsedAt = DateTime.UtcNow; UsageCount++; UpdatedAt = DateTime.UtcNow; } public void UpdatePermissions(McpPermissionSet newPermissions) { if (Status == TokenStatus.Revoked) throw new InvalidOperationException("Cannot update revoked token"); Permissions = newPermissions; UpdatedAt = DateTime.UtcNow; AddDomainEvent(new McpTokenPermissionsUpdatedEvent(Id, newPermissions)); } public void Rename(TokenName newName) { if (Status == TokenStatus.Revoked) throw new InvalidOperationException("Cannot rename revoked token"); Name = newName; UpdatedAt = DateTime.UtcNow; } public bool IsExpired() { return ExpiresAt.HasValue && ExpiresAt.Value < DateTime.UtcNow; } public bool HasPermission(string resource, string operation) { return Permissions.HasPermission(resource, operation); } private bool IsIpAllowed(string ipAddress) { if (string.IsNullOrEmpty(IpWhitelist)) return true; // Parse JSON array of whitelisted IPs var allowedIps = System.Text.Json.JsonSerializer.Deserialize(IpWhitelist); return allowedIps?.Contains(ipAddress) ?? false; } } ``` ### Value Objects **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/ValueObjects/McpTokenId.cs` ```csharp using ColaFlow.Domain.Common; namespace ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; public sealed class McpTokenId : ValueObject { public Guid Value { get; } private McpTokenId(Guid value) { Value = value; } public static McpTokenId CreateUnique() => new(Guid.NewGuid()); public static McpTokenId Create(Guid value) { if (value == Guid.Empty) throw new ArgumentException("MCP Token ID cannot be empty", nameof(value)); return new McpTokenId(value); } protected override IEnumerable GetEqualityComponents() { yield return Value; } public override string ToString() => Value.ToString(); public static implicit operator Guid(McpTokenId id) => id.Value; } ``` **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/ValueObjects/TokenName.cs` ```csharp using ColaFlow.Domain.Common; namespace ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; public sealed class TokenName : ValueObject { public string Value { get; } private TokenName(string value) { Value = value; } public static TokenName Create(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Token name cannot be empty", nameof(value)); if (value.Length < 3) throw new ArgumentException("Token name must be at least 3 characters", nameof(value)); if (value.Length > 100) throw new ArgumentException("Token name cannot exceed 100 characters", nameof(value)); return new TokenName(value.Trim()); } protected override IEnumerable GetEqualityComponents() { yield return Value; } public override string ToString() => Value; public static implicit operator string(TokenName name) => name.Value; } ``` **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/ValueObjects/McpPermissionSet.cs` ```csharp using ColaFlow.Domain.Common; namespace ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; /// /// Represents a set of permissions for MCP token /// public sealed class McpPermissionSet : ValueObject { public Dictionary Permissions { get; } private McpPermissionSet(Dictionary permissions) { Permissions = permissions; } public static McpPermissionSet Create(Dictionary permissions) { if (permissions is null || permissions.Count == 0) throw new ArgumentException("Permissions cannot be empty", nameof(permissions)); // Validate resources var validResources = new[] { "projects", "issues", "documents", "reports", "sprints", "users" }; var invalidResources = permissions.Keys.Except(validResources).ToArray(); if (invalidResources.Any()) throw new ArgumentException($"Invalid resources: {string.Join(", ", invalidResources)}"); // Validate operations var validOperations = new[] { "read", "create", "update", "delete", "search" }; foreach (var (resource, operations) in permissions) { var invalidOps = operations.Except(validOperations).ToArray(); if (invalidOps.Any()) throw new ArgumentException($"Invalid operations for {resource}: {string.Join(", ", invalidOps)}"); } return new McpPermissionSet(new Dictionary(permissions)); } // Predefined permission sets public static McpPermissionSet ReadOnly() => Create(new Dictionary { ["projects"] = new[] { "read", "search" }, ["issues"] = new[] { "read", "search" }, ["documents"] = new[] { "read", "search" }, ["reports"] = new[] { "read" } }); public static McpPermissionSet ReadWrite() => Create(new Dictionary { ["projects"] = new[] { "read", "search" }, ["issues"] = new[] { "read", "create", "update", "search" }, ["documents"] = new[] { "read", "create", "search" }, ["reports"] = new[] { "read" } }); public static McpPermissionSet FullAccess() => Create(new Dictionary { ["projects"] = new[] { "read", "create", "update", "search" }, ["issues"] = new[] { "read", "create", "update", "delete", "search" }, ["documents"] = new[] { "read", "create", "update", "delete", "search" }, ["reports"] = new[] { "read", "create" }, ["sprints"] = new[] { "read", "create", "update", "search" } }); public bool HasPermission(string resource, string operation) { if (!Permissions.TryGetValue(resource, out var operations)) return false; return operations.Contains(operation, StringComparer.OrdinalIgnoreCase); } public string ToJson() { return System.Text.Json.JsonSerializer.Serialize(Permissions); } public static McpPermissionSet FromJson(string json) { var permissions = System.Text.Json.JsonSerializer.Deserialize>(json); return Create(permissions!); } protected override IEnumerable GetEqualityComponents() { foreach (var (resource, operations) in Permissions.OrderBy(x => x.Key)) { yield return resource; foreach (var op in operations.OrderBy(x => x)) yield return op; } } } ``` ### Enumerations **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/Enums.cs` ```csharp namespace ColaFlow.Domain.Aggregates.McpTokenAggregate; public enum TokenStatus { Active = 1, Expired = 2, Revoked = 3 } ``` ### Domain Events **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/Events/McpTokenCreatedEvent.cs` ```csharp using ColaFlow.Domain.Common; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects; namespace ColaFlow.Domain.Aggregates.McpTokenAggregate.Events; public sealed record McpTokenCreatedEvent( McpTokenId TokenId, TenantId TenantId, TokenName Name) : IDomainEvent; ``` **File**: `src/ColaFlow.Domain/Aggregates/McpTokenAggregate/Events/McpTokenRevokedEvent.cs` ```csharp using ColaFlow.Domain.Common; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; namespace ColaFlow.Domain.Aggregates.McpTokenAggregate.Events; public sealed record McpTokenRevokedEvent( McpTokenId TokenId, string Reason) : IDomainEvent; ``` --- ## MCP Token Format ### Token Structure ``` mcp__ ``` **Examples**: - `mcp_acme_7f3d8a9c4e1b2f5a6d8c9e0f1a2b3c4d` - `mcp_beta_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` ### Token Generation **File**: `src/ColaFlow.Infrastructure/Services/McpTokenGenerator.cs` ```csharp using System.Security.Cryptography; using System.Text; namespace ColaFlow.Infrastructure.Services; public interface IMcpTokenGenerator { string GenerateToken(string tenantSlug); string HashToken(string token); bool VerifyToken(string token, string hash); } public sealed class McpTokenGenerator : IMcpTokenGenerator { public string GenerateToken(string tenantSlug) { // Generate cryptographically secure random bytes var randomBytes = new byte[24]; // 24 bytes = 32 base64 chars using var rng = RandomNumberGenerator.Create(); rng.GetBytes(randomBytes); // Convert to base64 and make URL-safe var randomPart = Convert.ToBase64String(randomBytes) .Replace("+", "") .Replace("/", "") .Replace("=", "") .ToLowerInvariant() .Substring(0, 32); return $"mcp_{tenantSlug}_{randomPart}"; } public string HashToken(string token) { using var sha256 = SHA256.Create(); var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(token)); return Convert.ToBase64String(hashBytes); } public bool VerifyToken(string token, string hash) { var computedHash = HashToken(token); return computedHash == hash; } } ``` --- ## Permission Model Design ### Permission Schema ```json { "projects": ["read", "search"], "issues": ["read", "create", "update", "search"], "documents": ["read", "create", "search"], "reports": ["read"], "sprints": ["read", "search"] } ``` ### Resource Types - `projects`: Project management - `issues`: Issue/task management - `documents`: Documentation - `reports`: Analytics and reports - `sprints`: Sprint management - `users`: User management (admin only) ### Operation Types - `read`: Read single resource - `create`: Create new resource - `update`: Update existing resource - `delete`: Delete resource (restricted) - `search`: Search/list resources ### Restriction Rules 1. **No Delete for Issues**: AI agents should not delete issues (data loss risk) 2. **No User Management**: AI agents cannot create/modify users 3. **Read-only Reports**: AI can read but not modify analytics 4. **Project-scoped**: All operations scoped to accessible projects --- ## Token Generation Flow ### Create MCP Token Command **File**: `src/ColaFlow.Application/McpTokens/Commands/CreateMcpToken/CreateMcpTokenCommand.cs` ```csharp using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; namespace ColaFlow.Application.McpTokens.Commands.CreateMcpToken; public sealed record CreateMcpTokenCommand( string Name, Dictionary Permissions, DateTime? ExpiresAt = null, string[]? IpWhitelist = null) : IRequest; public sealed record CreateMcpTokenResult( Guid TokenId, string Token, // Plain-text token (shown only once) string Name, DateTime CreatedAt, DateTime? ExpiresAt); ``` **File**: `src/ColaFlow.Application/McpTokens/Commands/CreateMcpToken/CreateMcpTokenCommandHandler.cs` ```csharp using ColaFlow.Application.Common.Interfaces; using ColaFlow.Infrastructure.Persistence; using ColaFlow.Infrastructure.Services; using ColaFlow.Domain.Aggregates.McpTokenAggregate; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using Microsoft.EntityFrameworkCore; namespace ColaFlow.Application.McpTokens.Commands.CreateMcpToken; public sealed class CreateMcpTokenCommandHandler : IRequestHandler { private readonly ITenantContext _tenantContext; private readonly IUserContext _userContext; private readonly ApplicationDbContext _context; private readonly IMcpTokenGenerator _tokenGenerator; private readonly ILogger _logger; public CreateMcpTokenCommandHandler( ITenantContext tenantContext, IUserContext userContext, ApplicationDbContext context, IMcpTokenGenerator tokenGenerator, ILogger logger) { _tenantContext = tenantContext; _userContext = userContext; _context = context; _tokenGenerator = tokenGenerator; _logger = logger; } public async Task Handle( CreateMcpTokenCommand request, CancellationToken cancellationToken) { // 1. Validate permissions var permissions = McpPermissionSet.Create(request.Permissions); // 2. Get tenant slug var tenant = await _context.Tenants .IgnoreQueryFilters() .FirstAsync(t => t.Id == _tenantContext.CurrentTenantId, cancellationToken); // 3. Generate token var plainTextToken = _tokenGenerator.GenerateToken(tenant.Slug); var tokenHash = _tokenGenerator.HashToken(plainTextToken); // 4. Create token entity var name = TokenName.Create(request.Name); var ipWhitelist = request.IpWhitelist is not null ? System.Text.Json.JsonSerializer.Serialize(request.IpWhitelist) : null; var mcpToken = McpToken.Create( _tenantContext.CurrentTenantId, _userContext.CurrentUserId, name, tokenHash, permissions, request.ExpiresAt, ipWhitelist); // 5. Persist await _context.McpTokens.AddAsync(mcpToken, cancellationToken); await _context.SaveChangesAsync(cancellationToken); _logger.LogInformation("MCP token created: {TokenId} by user {UserId}", mcpToken.Id, _userContext.CurrentUserId); // 6. Return plain-text token (ONLY TIME IT'S SHOWN) return new CreateMcpTokenResult( mcpToken.Id, plainTextToken, // WARNING: Store this, won't be shown again mcpToken.Name, mcpToken.CreatedAt, mcpToken.ExpiresAt); } } ``` **File**: `src/ColaFlow.Application/McpTokens/Commands/CreateMcpToken/CreateMcpTokenCommandValidator.cs` ```csharp using FluentValidation; namespace ColaFlow.Application.McpTokens.Commands.CreateMcpToken; public sealed class CreateMcpTokenCommandValidator : AbstractValidator { public CreateMcpTokenCommandValidator() { RuleFor(x => x.Name) .NotEmpty().WithMessage("Token name is required") .MinimumLength(3).WithMessage("Token name must be at least 3 characters") .MaximumLength(100).WithMessage("Token name cannot exceed 100 characters"); RuleFor(x => x.Permissions) .NotEmpty().WithMessage("At least one permission is required") .Must(p => p.Count > 0).WithMessage("Permissions cannot be empty"); RuleFor(x => x.ExpiresAt) .Must(date => !date.HasValue || date.Value > DateTime.UtcNow) .WithMessage("Expiration date must be in the future"); } } ``` --- ## Token Validation Flow ### Validate MCP Token Query **File**: `src/ColaFlow.Application/McpTokens/Queries/ValidateMcpToken/ValidateMcpTokenQuery.cs` ```csharp namespace ColaFlow.Application.McpTokens.Queries.ValidateMcpToken; public sealed record ValidateMcpTokenQuery( string Token, string IpAddress) : IRequest; public sealed record ValidateMcpTokenResult( Guid TokenId, Guid TenantId, string TenantSlug, Guid? UserId, McpPermissionSetDto Permissions); public sealed record McpPermissionSetDto( Dictionary Permissions); ``` **File**: `src/ColaFlow.Application/McpTokens/Queries/ValidateMcpToken/ValidateMcpTokenQueryHandler.cs` ```csharp using Microsoft.EntityFrameworkCore; using ColaFlow.Infrastructure.Persistence; using ColaFlow.Infrastructure.Services; namespace ColaFlow.Application.McpTokens.Queries.ValidateMcpToken; public sealed class ValidateMcpTokenQueryHandler : IRequestHandler { private readonly ApplicationDbContext _context; private readonly IMcpTokenGenerator _tokenGenerator; private readonly ILogger _logger; public ValidateMcpTokenQueryHandler( ApplicationDbContext context, IMcpTokenGenerator tokenGenerator, ILogger logger) { _context = context; _tokenGenerator = tokenGenerator; _logger = logger; } public async Task Handle( ValidateMcpTokenQuery request, CancellationToken cancellationToken) { try { // 1. Hash the token var tokenHash = _tokenGenerator.HashToken(request.Token); // 2. Find token in database (bypass tenant filter) var mcpToken = await _context.McpTokens .IgnoreQueryFilters() .Include(t => t.Tenant) .FirstOrDefaultAsync(t => t.TokenHash == tokenHash, cancellationToken); if (mcpToken is null) { _logger.LogWarning("MCP token not found"); return null; } // 3. Validate token status if (mcpToken.Status == TokenStatus.Revoked) { _logger.LogWarning("MCP token {TokenId} is revoked", mcpToken.Id); return null; } if (mcpToken.IsExpired()) { _logger.LogWarning("MCP token {TokenId} is expired", mcpToken.Id); return null; } // 4. Record usage (includes IP whitelist check) try { mcpToken.RecordUsage(request.IpAddress); await _context.SaveChangesAsync(cancellationToken); } catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, "IP address {IpAddress} not whitelisted for token {TokenId}", request.IpAddress, mcpToken.Id); return null; } // 5. Return validation result return new ValidateMcpTokenResult( mcpToken.Id, mcpToken.TenantId, mcpToken.Tenant.Slug, mcpToken.UserId, new McpPermissionSetDto(mcpToken.Permissions.Permissions)); } catch (Exception ex) { _logger.LogError(ex, "Error validating MCP token"); return null; } } } ``` --- ## MCP Authentication Middleware **File**: `src/ColaFlow.API/Middleware/McpAuthenticationMiddleware.cs` ```csharp using MediatR; using ColaFlow.Application.McpTokens.Queries.ValidateMcpToken; namespace ColaFlow.API.Middleware; /// /// Validates MCP token from Authorization header and injects tenant/user context /// public sealed class McpAuthenticationMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; public McpAuthenticationMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context, IMediator mediator) { // Only apply to MCP endpoints if (!context.Request.Path.StartsWithSegments("/api/mcp")) { await _next(context); return; } // 1. Extract token from Authorization header var authHeader = context.Request.Headers.Authorization.ToString(); if (string.IsNullOrEmpty(authHeader)) { await UnauthorizedResponse(context, "Missing Authorization header"); return; } if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { await UnauthorizedResponse(context, "Invalid Authorization header format"); return; } var token = authHeader.Substring("Bearer ".Length).Trim(); if (!token.StartsWith("mcp_")) { await UnauthorizedResponse(context, "Invalid token format"); return; } // 2. Validate token var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var validationResult = await mediator.Send(new ValidateMcpTokenQuery(token, ipAddress)); if (validationResult is null) { await UnauthorizedResponse(context, "Invalid or expired token"); return; } // 3. Inject tenant and user context into HTTP context context.Items["TenantId"] = validationResult.TenantId; context.Items["TenantSlug"] = validationResult.TenantSlug; context.Items["UserId"] = validationResult.UserId; context.Items["McpTokenId"] = validationResult.TokenId; context.Items["McpPermissions"] = validationResult.Permissions; _logger.LogInformation("MCP token validated: {TokenId} for tenant {TenantSlug}", validationResult.TokenId, validationResult.TenantSlug); // 4. Continue to next middleware await _next(context); } private static async Task UnauthorizedResponse(HttpContext context, string message) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new { error = message }); } } // Extension method public static class McpAuthenticationMiddlewareExtensions { public static IApplicationBuilder UseMcpAuthentication(this IApplicationBuilder app) { return app.UseMiddleware(); } } ``` ### Middleware Registration **File**: `src/ColaFlow.API/Program.cs` ```csharp var app = builder.Build(); app.UseHttpsRedirection(); app.UseTenantResolution(); // MCP authentication BEFORE general authentication app.UseMcpAuthentication(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run(); ``` --- ## Permission Enforcement ### MCP Permission Service **File**: `src/ColaFlow.Application/Common/Interfaces/IMcpPermissionService.cs` ```csharp namespace ColaFlow.Application.Common.Interfaces; public interface IMcpPermissionService { bool HasPermission(string resource, string operation); void RequirePermission(string resource, string operation); } ``` **File**: `src/ColaFlow.Infrastructure/Services/McpPermissionService.cs` ```csharp using Microsoft.AspNetCore.Http; using ColaFlow.Application.Common.Interfaces; using ColaFlow.Application.McpTokens.Queries.ValidateMcpToken; namespace ColaFlow.Infrastructure.Services; public sealed class McpPermissionService : IMcpPermissionService { private readonly IHttpContextAccessor _httpContextAccessor; public McpPermissionService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public bool HasPermission(string resource, string operation) { var httpContext = _httpContextAccessor.HttpContext; if (httpContext is null) return false; if (!httpContext.Items.TryGetValue("McpPermissions", out var permissionsObj)) return false; if (permissionsObj is not McpPermissionSetDto permissions) return false; if (!permissions.Permissions.TryGetValue(resource, out var operations)) return false; return operations.Contains(operation, StringComparer.OrdinalIgnoreCase); } public void RequirePermission(string resource, string operation) { if (!HasPermission(resource, operation)) { throw new UnauthorizedAccessException( $"MCP token does not have permission: {resource}.{operation}"); } } } ``` ### Usage in MCP Controllers **File**: `src/ColaFlow.API/Controllers/Mcp/McpIssuesController.cs` ```csharp using Microsoft.AspNetCore.Mvc; using MediatR; using ColaFlow.Application.Common.Interfaces; using ColaFlow.Application.Issues.Commands.CreateIssue; using ColaFlow.Application.Issues.Queries.GetIssue; namespace ColaFlow.API.Controllers.Mcp; [ApiController] [Route("api/mcp/issues")] public sealed class McpIssuesController : ControllerBase { private readonly IMediator _mediator; private readonly IMcpPermissionService _permissionService; public McpIssuesController(IMediator mediator, IMcpPermissionService permissionService) { _mediator = mediator; _permissionService = permissionService; } [HttpGet("{id}")] public async Task GetIssue(Guid id) { // Check permission _permissionService.RequirePermission("issues", "read"); var query = new GetIssueQuery(id); var result = await _mediator.Send(query); return result is not null ? Ok(result) : NotFound(); } [HttpPost] public async Task CreateIssue([FromBody] CreateIssueRequest request) { // Check permission _permissionService.RequirePermission("issues", "create"); var command = new CreateIssueCommand( request.ProjectId, request.Title, request.Description); var result = await _mediator.Send(command); return CreatedAtAction(nameof(GetIssue), new { id = result.IssueId }, result); } [HttpPut("{id}")] public async Task UpdateIssue(Guid id, [FromBody] UpdateIssueRequest request) { // Check permission _permissionService.RequirePermission("issues", "update"); // ... implementation return NoContent(); } [HttpDelete("{id}")] public async Task DeleteIssue(Guid id) { // Check permission (should fail for most MCP tokens) _permissionService.RequirePermission("issues", "delete"); // ... implementation return NoContent(); } } public sealed record CreateIssueRequest(Guid ProjectId, string Title, string? Description); public sealed record UpdateIssueRequest(string? Title, string? Description, string? Status); ``` --- ## Audit Logging ### McpAuditLog Entity **File**: `src/ColaFlow.Domain/Entities/McpAuditLog.cs` ```csharp using ColaFlow.Domain.Common; using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects; namespace ColaFlow.Domain.Entities; /// /// Audit log for all MCP operations /// public sealed class McpAuditLog : Entity { public TenantId TenantId { get; private set; } public McpTokenId TokenId { get; private set; } public UserId? UserId { get; private set; } // Request details public string HttpMethod { get; private set; } public string Endpoint { get; private set; } public string? RequestBody { get; private set; } // Response details public int StatusCode { get; private set; } public string? ResponseBody { get; private set; } // Security public string IpAddress { get; private set; } public string? UserAgent { get; private set; } // Timing public DateTime Timestamp { get; private set; } public int DurationMs { get; private set; } // Error tracking public string? ErrorMessage { get; private set; } private McpAuditLog() { } public static McpAuditLog Create( TenantId tenantId, McpTokenId tokenId, UserId? userId, string httpMethod, string endpoint, string? requestBody, int statusCode, string? responseBody, string ipAddress, string? userAgent, int durationMs, string? errorMessage = null) { return new McpAuditLog { Id = Guid.NewGuid(), TenantId = tenantId, TokenId = tokenId, UserId = userId, HttpMethod = httpMethod, Endpoint = endpoint, RequestBody = requestBody, StatusCode = statusCode, ResponseBody = responseBody, IpAddress = ipAddress, UserAgent = userAgent, Timestamp = DateTime.UtcNow, DurationMs = durationMs, ErrorMessage = errorMessage }; } } ``` ### Audit Logging Middleware **File**: `src/ColaFlow.API/Middleware/McpAuditLoggingMiddleware.cs` ```csharp using System.Diagnostics; using System.Text; using ColaFlow.Infrastructure.Persistence; using ColaFlow.Domain.Entities; using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects; namespace ColaFlow.API.Middleware; public sealed class McpAuditLoggingMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; public McpAuditLoggingMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; } public async Task InvokeAsync(HttpContext context, ApplicationDbContext dbContext) { // Only log MCP endpoints if (!context.Request.Path.StartsWithSegments("/api/mcp")) { await _next(context); return; } var stopwatch = Stopwatch.StartNew(); // Capture request body context.Request.EnableBuffering(); var requestBody = await ReadRequestBody(context.Request); // Capture response body var originalResponseBody = context.Response.Body; using var responseBodyStream = new MemoryStream(); context.Response.Body = responseBodyStream; string? errorMessage = null; try { await _next(context); } catch (Exception ex) { errorMessage = ex.Message; throw; } finally { stopwatch.Stop(); // Read response responseBodyStream.Seek(0, SeekOrigin.Begin); var responseBody = await new StreamReader(responseBodyStream).ReadToEndAsync(); // Copy response back to original stream responseBodyStream.Seek(0, SeekOrigin.Begin); await responseBodyStream.CopyToAsync(originalResponseBody); // Create audit log if (context.Items.TryGetValue("TenantId", out var tenantIdObj) && context.Items.TryGetValue("McpTokenId", out var tokenIdObj)) { var tenantId = TenantId.Create((Guid)tenantIdObj); var tokenId = McpTokenId.Create((Guid)tokenIdObj); var userId = context.Items.TryGetValue("UserId", out var userIdObj) && userIdObj is Guid userIdGuid ? UserId.Create(userIdGuid) : null; var auditLog = McpAuditLog.Create( tenantId, tokenId, userId, context.Request.Method, context.Request.Path, requestBody, context.Response.StatusCode, responseBody, context.Connection.RemoteIpAddress?.ToString() ?? "unknown", context.Request.Headers.UserAgent.ToString(), (int)stopwatch.ElapsedMilliseconds, errorMessage); await dbContext.McpAuditLogs.AddAsync(auditLog); await dbContext.SaveChangesAsync(); _logger.LogInformation("MCP audit log created: {Method} {Endpoint} - {StatusCode} ({DurationMs}ms)", context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds); } } } private static async Task ReadRequestBody(HttpRequest request) { if (request.ContentLength is null or 0) return null; request.Body.Seek(0, SeekOrigin.Begin); using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true); var body = await reader.ReadToEndAsync(); request.Body.Seek(0, SeekOrigin.Begin); return body; } } public static class McpAuditLoggingMiddlewareExtensions { public static IApplicationBuilder UseMcpAuditLogging(this IApplicationBuilder app) { return app.UseMiddleware(); } } ``` --- ## Database Schema ### MCP Tokens Table ```sql -- Table: mcp_tokens CREATE TABLE mcp_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL, -- Token details name VARCHAR(100) NOT NULL, token_hash VARCHAR(255) NOT NULL UNIQUE, -- SHA256 hash permissions JSONB NOT NULL, -- McpPermissionSet JSON -- Status status INT NOT NULL DEFAULT 1, -- 1=Active, 2=Expired, 3=Revoked created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NULL, expires_at TIMESTAMP NULL, revoked_at TIMESTAMP NULL, revocation_reason TEXT NULL, -- Usage tracking last_used_at TIMESTAMP NULL, usage_count INT NOT NULL DEFAULT 0, -- Security ip_whitelist JSONB NULL -- Array of allowed IP addresses ); -- Indexes CREATE INDEX idx_mcp_tokens_tenant_id ON mcp_tokens(tenant_id); CREATE INDEX idx_mcp_tokens_token_hash ON mcp_tokens(token_hash); CREATE INDEX idx_mcp_tokens_tenant_status ON mcp_tokens(tenant_id, status); CREATE INDEX idx_mcp_tokens_user_id ON mcp_tokens(user_id) WHERE user_id IS NOT NULL; -- Comments COMMENT ON TABLE mcp_tokens IS 'API tokens for MCP (AI agent) authentication'; COMMENT ON COLUMN mcp_tokens.token_hash IS 'SHA256 hash of the token (never store plain-text)'; COMMENT ON COLUMN mcp_tokens.permissions IS 'Fine-grained permissions in JSON format'; ``` ### MCP Audit Logs Table ```sql -- Table: mcp_audit_logs CREATE TABLE mcp_audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, token_id UUID NOT NULL REFERENCES mcp_tokens(id) ON DELETE CASCADE, user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL, -- Request details http_method VARCHAR(10) NOT NULL, endpoint VARCHAR(500) NOT NULL, request_body TEXT NULL, -- Response details status_code INT NOT NULL, response_body TEXT NULL, -- Security ip_address VARCHAR(50) NOT NULL, user_agent VARCHAR(500) NULL, -- Timing timestamp TIMESTAMP NOT NULL DEFAULT NOW(), duration_ms INT NOT NULL, -- Error tracking error_message TEXT NULL ); -- Indexes CREATE INDEX idx_mcp_audit_logs_tenant_id ON mcp_audit_logs(tenant_id); CREATE INDEX idx_mcp_audit_logs_token_id ON mcp_audit_logs(token_id); CREATE INDEX idx_mcp_audit_logs_timestamp ON mcp_audit_logs(timestamp DESC); CREATE INDEX idx_mcp_audit_logs_tenant_timestamp ON mcp_audit_logs(tenant_id, timestamp DESC); -- Partitioning (optional, for large scale) -- Partition by month for efficient querying and archival -- CREATE TABLE mcp_audit_logs_2025_01 PARTITION OF mcp_audit_logs -- FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); -- Comments COMMENT ON TABLE mcp_audit_logs IS 'Audit log for all MCP operations'; COMMENT ON COLUMN mcp_audit_logs.duration_ms IS 'Request processing duration in milliseconds'; ``` ### EF Core Configuration **File**: `src/ColaFlow.Infrastructure/Persistence/Configurations/McpTokenConfiguration.cs` ```csharp using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using ColaFlow.Domain.Aggregates.McpTokenAggregate; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.UserAggregate.ValueObjects; namespace ColaFlow.Infrastructure.Persistence.Configurations; public sealed class McpTokenConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("mcp_tokens"); builder.HasKey(t => t.Id); builder.Property(t => t.Id) .HasConversion(id => id.Value, value => McpTokenId.Create(value)) .HasColumnName("id"); builder.Property(t => t.TenantId) .HasConversion(id => id.Value, value => TenantId.Create(value)) .HasColumnName("tenant_id") .IsRequired(); builder.Property(t => t.UserId) .HasConversion(id => id!.Value, value => UserId.Create(value)) .HasColumnName("user_id"); builder.Property(t => t.Name) .HasConversion(name => name.Value, value => TokenName.Create(value)) .HasColumnName("name") .HasMaxLength(100) .IsRequired(); builder.Property(t => t.TokenHash) .HasColumnName("token_hash") .HasMaxLength(255) .IsRequired(); builder.HasIndex(t => t.TokenHash).IsUnique(); // Permissions stored as JSON builder.OwnsOne(t => t.Permissions, perm => { perm.ToJson("permissions"); perm.Property(p => p.Permissions).HasColumnName("permissions"); }); builder.Property(t => t.Status) .HasConversion() .HasColumnName("status") .IsRequired(); builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(t => t.UpdatedAt).HasColumnName("updated_at"); builder.Property(t => t.ExpiresAt).HasColumnName("expires_at"); builder.Property(t => t.RevokedAt).HasColumnName("revoked_at"); builder.Property(t => t.RevocationReason).HasColumnName("revocation_reason"); builder.Property(t => t.LastUsedAt).HasColumnName("last_used_at"); builder.Property(t => t.UsageCount).HasColumnName("usage_count").IsRequired(); builder.Property(t => t.IpWhitelist).HasColumnName("ip_whitelist"); // Relationships builder.HasOne() .WithMany() .HasForeignKey(t => t.TenantId) .OnDelete(DeleteBehavior.Cascade); builder.HasOne() .WithMany() .HasForeignKey(t => t.UserId) .OnDelete(DeleteBehavior.SetNull); // Indexes builder.HasIndex(t => t.TenantId); builder.HasIndex(t => new { t.TenantId, t.Status }); builder.HasIndex(t => t.UserId).HasFilter("user_id IS NOT NULL"); builder.Ignore(t => t.DomainEvents); } } ``` --- ## Frontend Token Management UI **File**: `src/frontend/app/settings/mcp-tokens/page.tsx` ```typescript 'use client'; import { useState } from 'react'; import { Table, Button, Modal, Form, Input, Select, Checkbox, Tag, message, Popconfirm } from 'antd'; import { CopyOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import dayjs from 'dayjs'; const { Option } = Select; interface McpToken { id: string; name: string; permissions: Record; createdAt: string; lastUsedAt?: string; expiresAt?: string; status: string; } export default function McpTokensPage() { const queryClient = useQueryClient(); const [isModalOpen, setIsModalOpen] = useState(false); const [newToken, setNewToken] = useState(null); const [form] = Form.useForm(); // Fetch tokens const { data: tokens, isLoading } = useQuery({ queryKey: ['mcp-tokens'], queryFn: () => fetch('/api/mcp-tokens').then(res => res.json()), }); // Create token const createToken = useMutation({ mutationFn: (values: any) => fetch('/api/mcp-tokens', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values), }).then(res => res.json()), onSuccess: (data) => { setNewToken(data.token); message.success('MCP token created successfully'); queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] }); }, }); // Revoke token const revokeToken = useMutation({ mutationFn: (tokenId: string) => fetch(`/api/mcp-tokens/${tokenId}/revoke`, { method: 'POST' }), onSuccess: () => { message.success('Token revoked successfully'); queryClient.invalidateQueries({ queryKey: ['mcp-tokens'] }); }, }); const handleCreateToken = (values: any) => { // Build permissions object const permissions: Record = {}; Object.entries(values).forEach(([key, value]) => { if (key.startsWith('perm_') && Array.isArray(value) && value.length > 0) { const resource = key.replace('perm_', ''); permissions[resource] = value as string[]; } }); createToken.mutate({ name: values.name, permissions, expiresAt: values.expiresAt ? dayjs(values.expiresAt).toISOString() : null, }); }; const copyToken = (token: string) => { navigator.clipboard.writeText(token); message.success('Token copied to clipboard'); }; const columns = [ { title: 'Name', dataIndex: 'name', key: 'name', }, { title: 'Permissions', dataIndex: 'permissions', key: 'permissions', render: (permissions: Record) => (
{Object.entries(permissions).slice(0, 3).map(([resource, ops]) => ( {resource}: {ops.join(', ')} ))} {Object.keys(permissions).length > 3 && ( +{Object.keys(permissions).length - 3} more )}
), }, { title: 'Last Used', dataIndex: 'lastUsedAt', key: 'lastUsedAt', render: (date?: string) => (date ? dayjs(date).fromNow() : 'Never'), }, { title: 'Expires', dataIndex: 'expiresAt', key: 'expiresAt', render: (date?: string) => (date ? dayjs(date).format('YYYY-MM-DD') : 'Never'), }, { title: 'Status', dataIndex: 'status', key: 'status', render: (status: string) => ( {status} ), }, { title: 'Actions', key: 'actions', render: (_: any, record: McpToken) => ( revokeToken.mutate(record.id)} > ), }, ]; return (

MCP Tokens

API tokens for AI agents to access ColaFlow via MCP protocol

{/* Create Token Modal */} form.submit()} onCancel={() => { setIsModalOpen(false); form.resetFields(); }} confirmLoading={createToken.isPending} width={700} >

Permissions

Select which resources and operations this token can access

{['projects', 'issues', 'documents', 'reports', 'sprints'].map((resource) => ( Read Create Update {resource !== 'issues' && Delete} Search ))}
{/* Display New Token Modal */} { setNewToken(null); setIsModalOpen(false); form.resetFields(); }} onCancel={() => { setNewToken(null); setIsModalOpen(false); form.resetFields(); }} footer={[ , ]} >

⚠️ Important: Save this token now!

This is the only time you'll see this token. Store it securely.

{newToken}
); } ``` --- ## Security Considerations ### 1. Token Storage - **Never store plain-text tokens**: Always hash with SHA256 - **Show token only once**: After creation, never display again - **Encrypt in transit**: HTTPS only - **Database encryption**: Enable encryption at rest for `mcp_tokens` table ### 2. Token Validation - **Constant-time comparison**: Prevent timing attacks - **Rate limiting**: Prevent brute-force attacks - **IP whitelisting**: Optional but recommended for production - **Expiration**: Set reasonable expiration dates (90 days max) ### 3. Permission Enforcement - **Fail closed**: Deny by default if permission unclear - **Audit all operations**: Log every MCP request - **No delete permissions**: AI agents should not delete data - **Read-only by default**: Require explicit write permissions ### 4. Revocation - **Instant revocation**: Token validation checks status in real-time - **Audit trail**: Log why token was revoked - **Notification**: Email user when their token is revoked --- ## Testing ### Unit Test - Token Generation **File**: `tests/ColaFlow.Domain.Tests/Aggregates/McpTokenTests.cs` ```csharp using ColaFlow.Domain.Aggregates.McpTokenAggregate; using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects; using ColaFlow.Domain.Aggregates.TenantAggregate.ValueObjects; using Xunit; namespace ColaFlow.Domain.Tests.Aggregates; public sealed class McpTokenTests { [Fact] public void Create_ShouldCreateActiveToken() { // Arrange var tenantId = TenantId.CreateUnique(); var name = TokenName.Create("Test Token"); var tokenHash = "hash123"; var permissions = McpPermissionSet.ReadOnly(); // Act var token = McpToken.Create(tenantId, null, name, tokenHash, permissions); // Assert Assert.Equal(TokenStatus.Active, token.Status); Assert.Equal(0, token.UsageCount); Assert.Null(token.LastUsedAt); } [Fact] public void Revoke_ShouldSetStatusToRevoked() { // Arrange var token = CreateTestToken(); // Act token.Revoke("No longer needed"); // Assert Assert.Equal(TokenStatus.Revoked, token.Status); Assert.NotNull(token.RevokedAt); Assert.Equal("No longer needed", token.RevocationReason); } [Fact] public void HasPermission_ShouldReturnTrueForAllowedOperation() { // Arrange var permissions = McpPermissionSet.Create(new Dictionary { ["issues"] = new[] { "read", "create" } }); var token = McpToken.Create( TenantId.CreateUnique(), null, TokenName.Create("Test"), "hash", permissions); // Act & Assert Assert.True(token.HasPermission("issues", "read")); Assert.True(token.HasPermission("issues", "create")); Assert.False(token.HasPermission("issues", "delete")); Assert.False(token.HasPermission("projects", "read")); } private static McpToken CreateTestToken() { return McpToken.Create( TenantId.CreateUnique(), null, TokenName.Create("Test Token"), "hash123", McpPermissionSet.ReadOnly()); } } ``` ### Integration Test - Token Validation **File**: `tests/ColaFlow.API.Tests/Mcp/McpAuthenticationTests.cs` ```csharp using System.Net; using System.Net.Http; using System.Net.Http.Headers; using Microsoft.AspNetCore.Mvc.Testing; using Xunit; namespace ColaFlow.API.Tests.Mcp; public sealed class McpAuthenticationTests : IClassFixture> { private readonly HttpClient _client; public McpAuthenticationTests(WebApplicationFactory factory) { _client = factory.CreateClient(); } [Fact] public async Task McpEndpoint_WithoutToken_ShouldReturn401() { // Act var response = await _client.GetAsync("/api/mcp/issues"); // Assert Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task McpEndpoint_WithInvalidToken_ShouldReturn401() { // Arrange _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid_token"); // Act var response = await _client.GetAsync("/api/mcp/issues"); // Assert Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task McpEndpoint_WithValidToken_ShouldSucceed() { // Arrange var validToken = "mcp_testtenant_validtoken123456789012345678"; _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", validToken); // Act var response = await _client.GetAsync("/api/mcp/issues"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); } } ``` --- ## Summary This MCP authentication architecture provides: ✅ **Secure Token Management**: SHA256 hashing, one-time display, instant revocation ✅ **Fine-Grained Permissions**: Resource + operation level control ✅ **Complete Audit Trail**: Every MCP operation logged with full context ✅ **Multi-Tenant Support**: Tokens scoped to single tenant ✅ **Production-Ready Security**: IP whitelisting, rate limiting, expiration ✅ **Beautiful UI**: Full-featured token management interface ✅ **AI-Friendly**: Designed for Claude, ChatGPT, and other AI agents **Next Steps**: 1. Update JWT authentication to include tenant claims 2. Execute database migration 3. Configure MCP Server with token authentication 4. Test with real AI agents (Claude Desktop, ChatGPT)