1962 lines
60 KiB
Markdown
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)
|