using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Core.Mcp; /// /// #713: What a token to the MCP server stands for — one agent, and whose run its calls /// belong to. A call through the server acts as this agent: with its tools, its rights, /// and nothing more. /// public sealed record McpGrant { public string AgentId { get; init; } = ""; /// The run the calls are audited under. public string RunId { get; init; } = ""; /// The task the run works on, if any. public string? TaskId { get; init; } public DateTime ExpiresAtUtc { get; init; } /// A token a human issued for their own client — not a run's. public bool IsPersonal { get; init; } /// The personal token's id; empty for a run's token. public string TokenId { get; init; } = ""; } /// #713: A personal token as it is listed — never the token itself. public sealed record McpPersonalToken( string Id, string AgentId, string Label, DateTime CreatedAtUtc, DateTime ExpiresAtUtc, DateTime? RevokedAtUtc) { public bool IsActive(DateTime nowUtc) => RevokedAtUtc is null && nowUtc < ExpiresAtUtc; } /// /// #713: The tokens of the MCP server. /// /// A run's token lives as long as the run. hands out a /// token and a handle; disposing the handle — at the end of the run — makes the token /// worthless, and it has an expiry of its own in case nobody disposes it. Run tokens exist /// only in memory: after a restart there is no run they could belong to. /// /// A personal token is for a human's own client (their Claude Code on the /// instance's taskboard). It acts as one agent, has an expiry, can be revoked, and is kept /// only as a hash: shown once, when it is issued. /// /// Tokens are compared by their SHA-256 — looked up, not compared character by /// character, so the time a check takes says nothing about how close a guess was. /// public sealed class McpGrants { /// A run that forgets to close its token loses it after this long anyway. public static readonly TimeSpan DefaultRunLifetime = TimeSpan.FromHours(4); /// The longest a personal token may live. public const int MaxPersonalDays = 90; private readonly ConcurrentDictionary _runs = new(StringComparer.Ordinal); private readonly SqliteStorage? _storage; private readonly TimeProvider _clock; public McpGrants(SqliteStorage? storage = null, TimeProvider? clock = null) { _storage = storage; _clock = clock ?? TimeProvider.System; } /// /// A token for one run of . Dispose the handle when the run ends. /// public McpRunToken OpenRun(string agentId, string runId, string? taskId = null, TimeSpan? lifetime = null) { var token = NewToken(); var hash = Hash(token); _runs[hash] = new McpGrant { AgentId = agentId, RunId = runId, TaskId = taskId, ExpiresAtUtc = _clock.GetUtcNow().UtcDateTime + (lifetime ?? DefaultRunLifetime) }; return new McpRunToken(token, () => _runs.TryRemove(hash, out _)); } /// What a token stands for, or null — unknown, expired or revoked. public async Task ResolveAsync(string? token, CancellationToken ct) { if (string.IsNullOrWhiteSpace(token)) return null; var hash = Hash(token.Trim()); var now = _clock.GetUtcNow().UtcDateTime; if (_runs.TryGetValue(hash, out var run)) { if (now < run.ExpiresAtUtc) return run; _runs.TryRemove(hash, out _); return null; } if (_storage is null) return null; await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT Id, AgentId, ExpiresAt, RevokedAt FROM McpTokens WHERE Hash = @hash"; cmd.Parameters.AddWithValue("@hash", hash); await using var reader = await cmd.ExecuteReaderAsync(ct); if (!await reader.ReadAsync(ct) || !reader.IsDBNull(3)) return null; var expires = Parse(reader.GetString(2)); if (now >= expires) return null; var id = reader.GetString(0); return new McpGrant { AgentId = reader.GetString(1), // Each personal token is its own "run" in the audit: its calls stay together. RunId = "mcp-" + id, ExpiresAtUtc = expires, IsPersonal = true, TokenId = id }; } /// Issues a personal token. Returns it in clear — the only time it exists so. public async Task<(McpPersonalToken Info, string Token)> IssuePersonalAsync( string agentId, string label, int days, CancellationToken ct) { var store = _storage ?? throw new InvalidOperationException("Personal tokens need the instance database."); var token = NewToken(); var now = _clock.GetUtcNow().UtcDateTime; var info = new McpPersonalToken( Guid.NewGuid().ToString("N")[..12], agentId, label.Trim(), now, now.AddDays(Math.Clamp(days, 1, MaxPersonalDays)), null); await store.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO McpTokens (Id, Hash, AgentId, Label, CreatedAt, ExpiresAt, RevokedAt) VALUES (@id, @hash, @agent, @label, @created, @expires, NULL) """; cmd.Parameters.AddWithValue("@id", info.Id); cmd.Parameters.AddWithValue("@hash", Hash(token)); cmd.Parameters.AddWithValue("@agent", info.AgentId); cmd.Parameters.AddWithValue("@label", info.Label); cmd.Parameters.AddWithValue("@created", Format(info.CreatedAtUtc)); cmd.Parameters.AddWithValue("@expires", Format(info.ExpiresAtUtc)); await cmd.ExecuteNonQueryAsync(ct); }, ct); return (info, token); } public async Task> ListPersonalAsync(CancellationToken ct) { if (_storage is null) return []; await using var conn = await _storage.OpenConnectionAsync(ct); using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT Id, AgentId, Label, CreatedAt, ExpiresAt, RevokedAt FROM McpTokens ORDER BY CreatedAt DESC"; var result = new List(); await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) result.Add(new McpPersonalToken( reader.GetString(0), reader.GetString(1), reader.GetString(2), Parse(reader.GetString(3)), Parse(reader.GetString(4)), reader.IsDBNull(5) ? null : Parse(reader.GetString(5)))); return result; } public async Task RevokeAsync(string id, CancellationToken ct) { if (_storage is null) return false; return await _storage.WriteAsync(async conn => { using var cmd = conn.CreateCommand(); cmd.CommandText = "UPDATE McpTokens SET RevokedAt = @now WHERE Id = @id AND RevokedAt IS NULL"; cmd.Parameters.AddWithValue("@now", Format(_clock.GetUtcNow().UtcDateTime)); cmd.Parameters.AddWithValue("@id", id.Trim()); return await cmd.ExecuteNonQueryAsync(ct) > 0; }, ct); } /// 32 random bytes, URL-safe — "cdn_" in front, so a leaked one is recognisable. private static string NewToken() => "cdn_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); private static string Format(DateTime value) => value.ToUniversalTime().ToString("O"); private static DateTime Parse(string value) => DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt) ? dt.ToUniversalTime() : DateTime.MinValue; } /// #713: A run's token and the handle that ends it. public sealed class McpRunToken(string token, Action close) : IDisposable { private int _closed; /// The token — to hand over by file (0600) or environment, never on a command line. public string Token { get; } = token; public void Dispose() { if (Interlocked.Exchange(ref _closed, 1) == 0) close(); } }