exec( "CREATE TABLE IF NOT EXISTS user_mcp_tokens ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, token_hash CHAR(64) NOT NULL, token_prefix VARCHAR(32) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT 'Default', scopes JSON NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, last_used_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at DATETIME NULL, PRIMARY KEY (id), KEY idx_hash (token_hash), KEY idx_user_active (user_id, is_active, revoked_at), CONSTRAINT fk_user_mcp_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" ); } public static function isUserEligible(int $userId): bool { return $userId > 0 && FreeTier::isPaidTier(FreeTier::tier($userId)); } public static function listForUser(int $userId): array { self::ensureSchema(); $db = dbnmDb(); $stmt = $db->prepare( 'SELECT id, token_prefix, name, scopes, is_active, last_used_at, created_at, revoked_at FROM user_mcp_tokens WHERE user_id = ? ORDER BY is_active DESC, created_at DESC' ); $stmt->execute([$userId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; foreach ($rows as &$row) { $row['id'] = (int)$row['id']; $row['is_active'] = (int)$row['is_active'] === 1; $row['scopes'] = json_decode((string)($row['scopes'] ?? '[]'), true) ?: []; } unset($row); return $rows; } public static function createForUser(int $userId, string $name = 'Default'): array { if (!self::isUserEligible($userId)) { throw new DbnToolsHttpException('MCP tokens require a Plus or Pro plan.', 403, 'not_paid'); } self::ensureSchema(); $plain = self::TOKEN_PREFIX . bin2hex(random_bytes(32)); $hash = hash('sha256', $plain); $prefix = substr($plain, 0, 24); $name = mb_substr(trim($name) !== '' ? trim($name) : 'Default', 0, 100, 'UTF-8'); $scopes = [ 'tools' => ['dbn.*'], 'privacy' => 'process-and-forget-default', 'tiers' => ['plus', 'pro'], ]; $db = dbnmDb(); $stmt = $db->prepare( 'INSERT INTO user_mcp_tokens (user_id, token_hash, token_prefix, name, scopes, is_active, created_at) VALUES (?, ?, ?, ?, ?, 1, NOW())' ); $stmt->execute([ $userId, $hash, $prefix, $name, json_encode($scopes, JSON_UNESCAPED_SLASHES), ]); return [ 'id' => (int)$db->lastInsertId(), 'token' => $plain, 'token_prefix' => $prefix, 'name' => $name, 'scopes' => $scopes, ]; } public static function revokeForUser(int $userId, int $tokenId): bool { self::ensureSchema(); $db = dbnmDb(); $stmt = $db->prepare( 'UPDATE user_mcp_tokens SET is_active = 0, revoked_at = NOW() WHERE id = ? AND user_id = ? AND is_active = 1 AND revoked_at IS NULL' ); $stmt->execute([$tokenId, $userId]); return $stmt->rowCount() > 0; } public static function resolve(string $token): ?array { $token = trim($token); if ($token === '' || !str_starts_with($token, self::TOKEN_PREFIX)) { return null; } self::ensureSchema(); $hash = hash('sha256', $token); $db = dbnmDb(); $stmt = $db->prepare( 'SELECT t.*, u.email FROM user_mcp_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ? AND t.is_active = 1 AND t.revoked_at IS NULL LIMIT 1' ); $stmt->execute([$hash]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { return null; } $userId = (int)$row['user_id']; $tier = FreeTier::tier($userId); if (!FreeTier::isPaidTier($tier)) { return null; } try { $db->prepare('UPDATE user_mcp_tokens SET last_used_at = NOW() WHERE id = ?') ->execute([(int)$row['id']]); } catch (Throwable $_) { // Token touch is best-effort. } $row['id'] = (int)$row['id']; $row['user_id'] = $userId; $row['tier'] = $tier; $row['scopes'] = json_decode((string)($row['scopes'] ?? '[]'), true) ?: []; return $row; } }