Files
ColaFlow/docs/architecture/mcp-authentication-architecture.md
Yaojia Wang fe8ad1c1f9
Some checks failed
Code Coverage / Generate Coverage Report (push) Has been cancelled
Tests / Run Tests (9.0.x) (push) Has been cancelled
Tests / Docker Build Test (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
In progress
2025-11-03 11:51:02 +01:00

1962 lines
60 KiB
Markdown

# 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_<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`
```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;
/// <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`
```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<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`
```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<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`
```csharp
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`
```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_<tenant_slug>_<random_32_chars>
```
**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<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`
```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<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`
```csharp
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`
```csharp
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`
```csharp
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`
```csharp
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`
```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<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`
```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;
/// <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`
```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<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
```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<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`
```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<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_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<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`
```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<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**:
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)