feat: внедрение RazorConsole TUI с runtime-управлением MCP-инструментами

- Добавлен RazorConsole.Core для интерактивного TUI-дашборда
- ToolRegistryService: живое включение/отключение модулей и отдельных методов
- InMemoryLogSink: кольцевой буфер логов с фильтрацией по модулю
- TUI: 3 таба (Overview, Logs, Settings)
- IToolModule: generic-интерфейс для легкого добавления новых MCP-модулей
- Guard-проверка TryCheckEnabled() во всех существующих инструментах
This commit is contained in:
2026-04-13 17:31:28 +03:00
parent c117d928b0
commit 879becadfe
24 changed files with 826 additions and 11 deletions

View File

@@ -1,13 +1,17 @@
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.Confluence;
[McpServerToolType]
public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, IConfiguration configuration)
public sealed class ConfluencePagesTools(
ConfluenceClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly RestClient? _client = provider.Client;
private readonly string? _clientInitializationError = provider.InitializationError;
@@ -15,11 +19,26 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
private readonly string _username = configuration["Confluence:Username"] ?? string.Empty;
private readonly string _spaceKey = configuration["Confluence:SpaceKey"] ?? string.Empty;
private const string ModuleName = "Confluence";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Confluence отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить страницу Confluence по ID")]
public async Task<string> GetPage(
[Description("ID страницы Confluence")] string pageId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetPage", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(pageId))
{
return "ID страницы Confluence не задан.";
@@ -64,6 +83,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
[Description("Максимум страниц в ответе")] int maxResults = 20,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("SearchPages", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -122,6 +143,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
[Description("ID родительской страницы. Опционально")] string? parentId = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreatePage", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -188,6 +211,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
[Description("Новое название страницы. Если пусто, не меняется")] string? title = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpdatePage", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -250,6 +275,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
[Description("ID страницы для удаления")] string pageId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeletePage", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -279,6 +306,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
[Description("Ключ пространства")] string spaceKey,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetSpace", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;

View File

@@ -0,0 +1,19 @@
using LazyBear.MCP.Services.ToolRegistry;
namespace LazyBear.MCP.Services.Confluence;
public sealed class ConfluenceToolModule : IToolModule
{
public string ModuleName => "Confluence";
public string Description => "Confluence: страницы, пространства, поиск";
public IReadOnlyList<string> ToolNames =>
[
"GetPage",
"SearchPages",
"CreatePage",
"UpdatePage",
"DeletePage",
"GetSpace"
];
}

View File

@@ -1,23 +1,42 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.Jira;
[McpServerToolType]
public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration configuration)
public sealed class JiraIssueTools(
JiraClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly RestClient? _client = provider.Client;
private readonly string? _clientInitializationError = provider.InitializationError;
private readonly string _token = configuration["Jira:Token"] ?? string.Empty;
private readonly string _defaultProject = configuration["Jira:Project"] ?? string.Empty;
private const string ModuleName = "Jira";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Jira отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить задачу Jira по ключу")]
public async Task<string> GetIssue(
[Description("Ключ задачи, например PROJ-123")] string issueKey,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetIssue", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(issueKey))
{
return "Ключ задачи Jira не задан.";
@@ -60,6 +79,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
[Description("Максимум задач в ответе")] int maxResults = 20,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssues", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -118,6 +139,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
[Description("Описание задачи")] string? description = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateIssue", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -190,6 +213,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
[Description("Новое описание")] string? description = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpdateIssue", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description))
{
return $"Нет полей для обновления задачи '{issueKey}'.";
@@ -256,6 +281,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
[Description("Ключ задачи")] string issueKey,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetIssueStatuses", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -301,6 +328,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
[Description("Максимум комментариев")] int limit = 20,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssueComments", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
@@ -349,6 +378,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
[Description("Текст комментария")] string body,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("AddComment", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(body))
{
return "Текст комментария Jira не задан.";

View File

@@ -0,0 +1,20 @@
using LazyBear.MCP.Services.ToolRegistry;
namespace LazyBear.MCP.Services.Jira;
public sealed class JiraToolModule : IToolModule
{
public string ModuleName => "Jira";
public string Description => "Jira: задачи, комментарии, переходы статусов";
public IReadOnlyList<string> ToolNames =>
[
"GetIssue",
"ListIssues",
"CreateIssue",
"UpdateIssue",
"GetIssueStatuses",
"ListIssueComments",
"AddComment"
];
}

View File

@@ -1,12 +1,18 @@
using System.ComponentModel;
using k8s;
using k8s.Autorest;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sConfigTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
public sealed class K8sConfigTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sConfigTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
private const int MaxSecretKeyLimit = 100;
@@ -15,6 +21,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListConfigMaps", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
@@ -52,6 +60,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetConfigMapData", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
@@ -83,6 +93,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListSecrets", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
@@ -120,6 +132,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetSecretKeys", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);

View File

@@ -3,12 +3,18 @@ using System.Text.Json;
using k8s;
using k8s.Autorest;
using k8s.Models;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sDeploymentTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
public sealed class K8sDeploymentTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sDeploymentTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
private const int MinReplicas = 0;
private const int MaxReplicas = 100;
@@ -18,6 +24,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListDeployments", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
@@ -58,6 +66,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ScaleDeployment", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
@@ -95,6 +105,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetRolloutStatus", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
@@ -126,6 +138,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("RestartDeployment", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);

View File

@@ -1,18 +1,26 @@
using System.ComponentModel;
using k8s;
using k8s.Autorest;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sNetworkTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
public sealed class K8sNetworkTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sNetworkTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
[McpServerTool, Description("Список service в namespace")]
public async Task<string> ListServices(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListServices", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
@@ -55,6 +63,8 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetServiceDetails", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
@@ -90,6 +100,8 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIngresses", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))

View File

@@ -1,12 +1,18 @@
using System.ComponentModel;
using k8s;
using k8s.Autorest;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sPodsTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
public sealed class K8sPodsTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sPodsTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
private const int MaxTailLines = 10;
private const int MinTailLines = 10;
@@ -16,6 +22,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListPods", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
@@ -55,6 +63,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetPodStatus", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
@@ -90,6 +100,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
int? tailLines = 100,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetPodLogs", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));

View File

@@ -0,0 +1,31 @@
using LazyBear.MCP.Services.ToolRegistry;
namespace LazyBear.MCP.Services.Kubernetes;
public sealed class KubernetesToolModule : IToolModule
{
public string ModuleName => "Kubernetes";
public string Description => "Kubernetes: поды, деплойменты, сервисы, конфиги";
public IReadOnlyList<string> ToolNames =>
[
// Pods
"ListPods",
"GetPodStatus",
"GetPodLogs",
// Deployments
"ListDeployments",
"ScaleDeployment",
"GetRolloutStatus",
"RestartDeployment",
// Network
"ListServices",
"GetServiceDetails",
"ListIngresses",
// Config
"ListConfigMaps",
"GetConfigMapData",
"ListSecrets",
"GetSecretKeys"
];
}

View File

@@ -1,18 +1,36 @@
using System.ComponentModel;
using System.Text.RegularExpressions;
using k8s;
using LazyBear.MCP.Services.ToolRegistry;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public abstract class KubernetesToolsBase(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null)
public abstract class KubernetesToolsBase(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger? logger = null)
{
protected readonly IKubernetes? _client = clientProvider.Client;
protected readonly string? _clientInitializationError = clientProvider.InitializationError;
protected readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
protected readonly ILogger? _logger = logger;
protected readonly ToolRegistryService _registry = registry;
protected const string K8sModuleName = "Kubernetes";
protected bool TryCheckEnabled(string toolName, out string error)
{
if (!_registry.IsToolEnabled(K8sModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Kubernetes отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
protected static readonly Regex NamespaceRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
protected static readonly Regex ResourceNameRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));

View File

@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace LazyBear.MCP.Services.Logging;
/// <summary>
/// Singleton, хранящий последние <see cref="Capacity"/> записей лога в памяти.
/// Безопасен для многопоточной записи.
/// </summary>
public sealed class InMemoryLogSink
{
public const int Capacity = 500;
private readonly ConcurrentQueue<LogEntry> _entries = new();
/// <summary>Событие вызывается при добавлении новой записи.</summary>
public event Action<LogEntry>? OnLog;
public void Add(LogEntry entry)
{
_entries.Enqueue(entry);
// Обрезаем старые записи
while (_entries.Count > Capacity)
{
_entries.TryDequeue(out _);
}
OnLog?.Invoke(entry);
}
/// <summary>
/// Возвращает снимок всех записей. Опциональный фильтр по категории-префиксу.
/// </summary>
public IReadOnlyList<LogEntry> GetEntries(string? categoryPrefix = null)
{
var all = _entries.ToArray();
if (string.IsNullOrWhiteSpace(categoryPrefix))
{
return all;
}
return Array.FindAll(all, e => e.Category.StartsWith(categoryPrefix, StringComparison.OrdinalIgnoreCase));
}
public void Clear() => _entries.Clear();
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
namespace LazyBear.MCP.Services.Logging;
/// <summary>
/// ILoggerProvider, направляющий все логи в <see cref="InMemoryLogSink"/>.
/// </summary>
[ProviderAlias("InMemory")]
public sealed class InMemoryLoggerProvider(InMemoryLogSink sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) =>
new InMemoryLogger(sink, categoryName);
public void Dispose() { }
}
internal sealed class InMemoryLogger(InMemoryLogSink sink, string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
var message = formatter(state, exception);
var entry = new LogEntry(
DateTimeOffset.Now,
logLevel,
category,
message,
exception?.ToString());
sink.Add(entry);
}
}

View File

@@ -0,0 +1,14 @@
namespace LazyBear.MCP.Services.Logging;
public sealed record LogEntry(
DateTimeOffset Timestamp,
LogLevel Level,
string Category,
string Message,
string? Exception = null)
{
/// <summary>Короткое имя категории (последний сегмент namespace)</summary>
public string ShortCategory => Category.Contains('.')
? Category[(Category.LastIndexOf('.') + 1)..]
: Category;
}

View File

@@ -0,0 +1,17 @@
namespace LazyBear.MCP.Services.ToolRegistry;
/// <summary>
/// Описывает группу MCP-инструментов (один интеграционный модуль).
/// Реализуйте этот интерфейс для регистрации новых модулей.
/// </summary>
public interface IToolModule
{
/// <summary>Уникальное имя модуля (Jira, Kubernetes, Confluence, …)</summary>
string ModuleName { get; }
/// <summary>Имена всех инструментов, входящих в модуль.</summary>
IReadOnlyList<string> ToolNames { get; }
/// <summary>Человекочитаемое описание модуля для TUI.</summary>
string Description { get; }
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Concurrent;
namespace LazyBear.MCP.Services.ToolRegistry;
/// <summary>
/// Singleton. Хранит runtime-состояние включённости модулей и отдельных инструментов.
/// Thread-safe. Уведомляет подписчиков при любом изменении.
/// </summary>
public sealed class ToolRegistryService
{
private readonly ConcurrentDictionary<string, bool> _moduleEnabled = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, bool> _toolEnabled = new(StringComparer.OrdinalIgnoreCase);
private readonly List<IToolModule> _modules = [];
private readonly Lock _modulesLock = new();
/// <summary>Вызывается при любом изменении состояния.</summary>
public event Action? StateChanged;
// ── Регистрация ──────────────────────────────────────────────────────────
public void RegisterModule(IToolModule module)
{
lock (_modulesLock)
{
if (_modules.Any(m => string.Equals(m.ModuleName, module.ModuleName, StringComparison.OrdinalIgnoreCase)))
return;
_modules.Add(module);
}
_moduleEnabled.TryAdd(module.ModuleName, true);
foreach (var tool in module.ToolNames)
{
_toolEnabled.TryAdd(MakeKey(module.ModuleName, tool), true);
}
}
// ── Запросы состояния ────────────────────────────────────────────────────
public IReadOnlyList<IToolModule> GetModules()
{
lock (_modulesLock)
{
return _modules.ToList();
}
}
public bool IsModuleEnabled(string moduleName) =>
_moduleEnabled.GetValueOrDefault(moduleName, true);
public bool IsToolEnabled(string moduleName, string toolName) =>
IsModuleEnabled(moduleName) &&
_toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true);
// ── Переключение ─────────────────────────────────────────────────────────
public void SetModuleEnabled(string moduleName, bool enabled)
{
_moduleEnabled[moduleName] = enabled;
StateChanged?.Invoke();
}
public void SetToolEnabled(string moduleName, string toolName, bool enabled)
{
_toolEnabled[MakeKey(moduleName, toolName)] = enabled;
StateChanged?.Invoke();
}
public void ToggleModule(string moduleName) =>
SetModuleEnabled(moduleName, !IsModuleEnabled(moduleName));
public void ToggleTool(string moduleName, string toolName) =>
SetToolEnabled(moduleName, toolName, !IsToolEnabled(moduleName, toolName));
// ── Счётчики для Overview ─────────────────────────────────────────────────
public (int Active, int Total) GetToolCounts(string moduleName)
{
lock (_modulesLock)
{
var module = _modules.FirstOrDefault(m =>
string.Equals(m.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase));
if (module is null) return (0, 0);
var total = module.ToolNames.Count;
var active = module.ToolNames.Count(t => IsToolEnabled(moduleName, t));
return (active, total);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string MakeKey(string module, string tool) => $"{module}::{tool}";
}