60 KiB
MCP Authentication Architecture
Table of Contents
- MCP Authentication Overview
- McpToken Entity Design
- MCP Token Format
- Permission Model Design
- Token Generation Flow
- Token Validation Flow
- MCP Authentication Middleware
- Permission Enforcement
- Audit Logging
- Database Schema
- Frontend Token Management UI
- Security Considerations
- 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:
- Long-lived tokens: AI agents use API tokens (not JWT)
- Fine-grained permissions: Each token has specific resource/operation permissions
- Audit trail: All MCP operations must be logged
- Tenant isolation: Tokens are scoped to a single tenant
- Revocable: Tokens can be revoked instantly
Architecture Overview
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_<tenant_slug>_<random_32> │
│ - 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
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;
/// <summary>
/// MCP Token aggregate root - represents an API token for AI agent authentication
/// </summary>
public sealed class McpToken : AggregateRoot<McpTokenId>
{
// 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<string[]>(IpWhitelist);
return allowedIps?.Contains(ipAddress) ?? false;
}
}
Value Objects
File: src/ColaFlow.Domain/Aggregates/McpTokenAggregate/ValueObjects/McpTokenId.cs
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<object> 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
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<object> 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
using ColaFlow.Domain.Common;
namespace ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects;
/// <summary>
/// Represents a set of permissions for MCP token
/// </summary>
public sealed class McpPermissionSet : ValueObject
{
public Dictionary<string, string[]> Permissions { get; }
private McpPermissionSet(Dictionary<string, string[]> permissions)
{
Permissions = permissions;
}
public static McpPermissionSet Create(Dictionary<string, string[]> 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<string, string[]>(permissions));
}
// Predefined permission sets
public static McpPermissionSet ReadOnly() => Create(new Dictionary<string, string[]>
{
["projects"] = new[] { "read", "search" },
["issues"] = new[] { "read", "search" },
["documents"] = new[] { "read", "search" },
["reports"] = new[] { "read" }
});
public static McpPermissionSet ReadWrite() => Create(new Dictionary<string, string[]>
{
["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<string, string[]>
{
["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<Dictionary<string, string[]>>(json);
return Create(permissions!);
}
protected override IEnumerable<object> 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
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
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
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_<tenant_slug>_<random_32_chars>
Examples:
mcp_acme_7f3d8a9c4e1b2f5a6d8c9e0f1a2b3c4dmcp_beta_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Token Generation
File: src/ColaFlow.Infrastructure/Services/McpTokenGenerator.cs
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
{
"projects": ["read", "search"],
"issues": ["read", "create", "update", "search"],
"documents": ["read", "create", "search"],
"reports": ["read"],
"sprints": ["read", "search"]
}
Resource Types
projects: Project managementissues: Issue/task managementdocuments: Documentationreports: Analytics and reportssprints: Sprint managementusers: User management (admin only)
Operation Types
read: Read single resourcecreate: Create new resourceupdate: Update existing resourcedelete: Delete resource (restricted)search: Search/list resources
Restriction Rules
- No Delete for Issues: AI agents should not delete issues (data loss risk)
- No User Management: AI agents cannot create/modify users
- Read-only Reports: AI can read but not modify analytics
- Project-scoped: All operations scoped to accessible projects
Token Generation Flow
Create MCP Token Command
File: src/ColaFlow.Application/McpTokens/Commands/CreateMcpToken/CreateMcpTokenCommand.cs
using ColaFlow.Domain.Aggregates.McpTokenAggregate.ValueObjects;
namespace ColaFlow.Application.McpTokens.Commands.CreateMcpToken;
public sealed record CreateMcpTokenCommand(
string Name,
Dictionary<string, string[]> Permissions,
DateTime? ExpiresAt = null,
string[]? IpWhitelist = null) : IRequest<CreateMcpTokenResult>;
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
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<CreateMcpTokenCommand, CreateMcpTokenResult>
{
private readonly ITenantContext _tenantContext;
private readonly IUserContext _userContext;
private readonly ApplicationDbContext _context;
private readonly IMcpTokenGenerator _tokenGenerator;
private readonly ILogger<CreateMcpTokenCommandHandler> _logger;
public CreateMcpTokenCommandHandler(
ITenantContext tenantContext,
IUserContext userContext,
ApplicationDbContext context,
IMcpTokenGenerator tokenGenerator,
ILogger<CreateMcpTokenCommandHandler> logger)
{
_tenantContext = tenantContext;
_userContext = userContext;
_context = context;
_tokenGenerator = tokenGenerator;
_logger = logger;
}
public async Task<CreateMcpTokenResult> 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
using FluentValidation;
namespace ColaFlow.Application.McpTokens.Commands.CreateMcpToken;
public sealed class CreateMcpTokenCommandValidator : AbstractValidator<CreateMcpTokenCommand>
{
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
namespace ColaFlow.Application.McpTokens.Queries.ValidateMcpToken;
public sealed record ValidateMcpTokenQuery(
string Token,
string IpAddress) : IRequest<ValidateMcpTokenResult?>;
public sealed record ValidateMcpTokenResult(
Guid TokenId,
Guid TenantId,
string TenantSlug,
Guid? UserId,
McpPermissionSetDto Permissions);
public sealed record McpPermissionSetDto(
Dictionary<string, string[]> Permissions);
File: src/ColaFlow.Application/McpTokens/Queries/ValidateMcpToken/ValidateMcpTokenQueryHandler.cs
using Microsoft.EntityFrameworkCore;
using ColaFlow.Infrastructure.Persistence;
using ColaFlow.Infrastructure.Services;
namespace ColaFlow.Application.McpTokens.Queries.ValidateMcpToken;
public sealed class ValidateMcpTokenQueryHandler
: IRequestHandler<ValidateMcpTokenQuery, ValidateMcpTokenResult?>
{
private readonly ApplicationDbContext _context;
private readonly IMcpTokenGenerator _tokenGenerator;
private readonly ILogger<ValidateMcpTokenQueryHandler> _logger;
public ValidateMcpTokenQueryHandler(
ApplicationDbContext context,
IMcpTokenGenerator tokenGenerator,
ILogger<ValidateMcpTokenQueryHandler> logger)
{
_context = context;
_tokenGenerator = tokenGenerator;
_logger = logger;
}
public async Task<ValidateMcpTokenResult?> 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
using MediatR;
using ColaFlow.Application.McpTokens.Queries.ValidateMcpToken;
namespace ColaFlow.API.Middleware;
/// <summary>
/// Validates MCP token from Authorization header and injects tenant/user context
/// </summary>
public sealed class McpAuthenticationMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<McpAuthenticationMiddleware> _logger;
public McpAuthenticationMiddleware(RequestDelegate next, ILogger<McpAuthenticationMiddleware> 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<McpAuthenticationMiddleware>();
}
}
Middleware Registration
File: src/ColaFlow.API/Program.cs
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
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
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
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<IActionResult> 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<IActionResult> 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<IActionResult> UpdateIssue(Guid id, [FromBody] UpdateIssueRequest request)
{
// Check permission
_permissionService.RequirePermission("issues", "update");
// ... implementation
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> 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
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;
/// <summary>
/// Audit log for all MCP operations
/// </summary>
public sealed class McpAuditLog : Entity<Guid>
{
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
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<McpAuditLoggingMiddleware> _logger;
public McpAuditLoggingMiddleware(RequestDelegate next, ILogger<McpAuditLoggingMiddleware> 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<string?> 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<McpAuditLoggingMiddleware>();
}
}
Database Schema
MCP Tokens Table
-- 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
-- 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
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<McpToken>
{
public void Configure(EntityTypeBuilder<McpToken> 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<int>()
.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<Tenant>()
.WithMany()
.HasForeignKey(t => t.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>()
.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
'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<string, string[]>;
createdAt: string;
lastUsedAt?: string;
expiresAt?: string;
status: string;
}
export default function McpTokensPage() {
const queryClient = useQueryClient();
const [isModalOpen, setIsModalOpen] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [form] = Form.useForm();
// Fetch tokens
const { data: tokens, isLoading } = useQuery<McpToken[]>({
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<string, string[]> = {};
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<string, string[]>) => (
<div className="flex flex-wrap gap-1">
{Object.entries(permissions).slice(0, 3).map(([resource, ops]) => (
<Tag key={resource} color="blue">
{resource}: {ops.join(', ')}
</Tag>
))}
{Object.keys(permissions).length > 3 && (
<Tag>+{Object.keys(permissions).length - 3} more</Tag>
)}
</div>
),
},
{
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) => (
<Tag color={status === 'Active' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: 'Actions',
key: 'actions',
render: (_: any, record: McpToken) => (
<Popconfirm
title="Revoke this token?"
description="This action cannot be undone."
onConfirm={() => revokeToken.mutate(record.id)}
>
<Button type="link" danger icon={<DeleteOutlined />}>
Revoke
</Button>
</Popconfirm>
),
},
];
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">MCP Tokens</h1>
<p className="text-gray-600">
API tokens for AI agents to access ColaFlow via MCP protocol
</p>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalOpen(true)}
>
Generate Token
</Button>
</div>
<Table
columns={columns}
dataSource={tokens}
rowKey="id"
loading={isLoading}
/>
{/* Create Token Modal */}
<Modal
title="Generate MCP Token"
open={isModalOpen && !newToken}
onOk={() => form.submit()}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
}}
confirmLoading={createToken.isPending}
width={700}
>
<Form form={form} layout="vertical" onFinish={handleCreateToken}>
<Form.Item
label="Token Name"
name="name"
rules={[{ required: true, message: 'Please enter a token name' }]}
>
<Input placeholder="e.g., Claude AI Agent" />
</Form.Item>
<Form.Item label="Expiration Date (Optional)" name="expiresAt">
<Input type="date" />
</Form.Item>
<div className="mb-4">
<h3 className="font-semibold mb-2">Permissions</h3>
<p className="text-sm text-gray-600 mb-3">
Select which resources and operations this token can access
</p>
{['projects', 'issues', 'documents', 'reports', 'sprints'].map((resource) => (
<Form.Item
key={resource}
label={resource.charAt(0).toUpperCase() + resource.slice(1)}
name={`perm_${resource}`}
>
<Checkbox.Group>
<Checkbox value="read">Read</Checkbox>
<Checkbox value="create">Create</Checkbox>
<Checkbox value="update">Update</Checkbox>
{resource !== 'issues' && <Checkbox value="delete">Delete</Checkbox>}
<Checkbox value="search">Search</Checkbox>
</Checkbox.Group>
</Form.Item>
))}
</div>
</Form>
</Modal>
{/* Display New Token Modal */}
<Modal
title="Token Created Successfully"
open={!!newToken}
onOk={() => {
setNewToken(null);
setIsModalOpen(false);
form.resetFields();
}}
onCancel={() => {
setNewToken(null);
setIsModalOpen(false);
form.resetFields();
}}
footer={[
<Button key="close" type="primary" onClick={() => setNewToken(null)}>
I've saved the token
</Button>,
]}
>
<div className="bg-yellow-50 border border-yellow-200 rounded p-4 mb-4">
<p className="text-yellow-800 font-semibold">
⚠️ Important: Save this token now!
</p>
<p className="text-yellow-700 text-sm">
This is the only time you'll see this token. Store it securely.
</p>
</div>
<div className="bg-gray-50 rounded p-4 font-mono text-sm break-all">
{newToken}
</div>
<Button
icon={<CopyOutlined />}
onClick={() => copyToken(newToken!)}
className="mt-3"
>
Copy to Clipboard
</Button>
</Modal>
</div>
);
}
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_tokenstable
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
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<string, string[]>
{
["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
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<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public McpAuthenticationTests(WebApplicationFactory<Program> 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:
- Update JWT authentication to include tenant claims
- Execute database migration
- Configure MCP Server with token authentication
- Test with real AI agents (Claude Desktop, ChatGPT)