diff --git a/LazyBear.MCP/LazyBear.MCP.csproj b/LazyBear.MCP/LazyBear.MCP.csproj index 8f664b2..6232bce 100644 --- a/LazyBear.MCP/LazyBear.MCP.csproj +++ b/LazyBear.MCP/LazyBear.MCP.csproj @@ -4,15 +4,17 @@ net10.0 enable enable + + false - + diff --git a/LazyBear.MCP/Program.cs b/LazyBear.MCP/Program.cs index 5f87554..7957c98 100644 --- a/LazyBear.MCP/Program.cs +++ b/LazyBear.MCP/Program.cs @@ -1,20 +1,41 @@ -using LazyBear.MCP.Services.Confluence; +using LazyBear.MCP.Services.Confluence; using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Kubernetes; +using LazyBear.MCP.Services.Logging; +using LazyBear.MCP.Services.ToolRegistry; +using LazyBear.MCP.TUI; using ModelContextProtocol.Server; var builder = WebApplication.CreateBuilder(args); +// ── InMemoryLogSink: регистрируем Singleton и кастомный логгер ─────────────── +var logSink = new InMemoryLogSink(); +builder.Services.AddSingleton(logSink); +builder.Logging.AddProvider(new InMemoryLoggerProvider(logSink)); + +// ── MCP-провайдеры ─────────────────────────────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// ── ToolRegistry ───────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); + +// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// ── MCP-сервер ─────────────────────────────────────────────────────────────── builder.Services.AddMcpServer() .WithHttpTransport() .WithToolsFromAssembly(); +// ── TUI как фоновый сервис ─────────────────────────────────────────────────── +builder.Services.AddHostedService(); + var app = builder.Build(); app.MapMcp(); -app.Run("http://localhost:5000"); \ No newline at end of file +app.Run("http://localhost:5000"); diff --git a/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs b/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs index 22fe2e9..92e800f 100644 --- a/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs +++ b/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs @@ -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 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; diff --git a/LazyBear.MCP/Services/Confluence/ConfluenceToolModule.cs b/LazyBear.MCP/Services/Confluence/ConfluenceToolModule.cs new file mode 100644 index 0000000..ea6d5f5 --- /dev/null +++ b/LazyBear.MCP/Services/Confluence/ConfluenceToolModule.cs @@ -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 ToolNames => + [ + "GetPage", + "SearchPages", + "CreatePage", + "UpdatePage", + "DeletePage", + "GetSpace" + ]; +} diff --git a/LazyBear.MCP/Services/Jira/JiraIssueTools.cs b/LazyBear.MCP/Services/Jira/JiraIssueTools.cs index 42d1948..6fa046e 100644 --- a/LazyBear.MCP/Services/Jira/JiraIssueTools.cs +++ b/LazyBear.MCP/Services/Jira/JiraIssueTools.cs @@ -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 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 не задан."; diff --git a/LazyBear.MCP/Services/Jira/JiraToolModule.cs b/LazyBear.MCP/Services/Jira/JiraToolModule.cs new file mode 100644 index 0000000..12b0187 --- /dev/null +++ b/LazyBear.MCP/Services/Jira/JiraToolModule.cs @@ -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 ToolNames => + [ + "GetIssue", + "ListIssues", + "CreateIssue", + "UpdateIssue", + "GetIssueStatuses", + "ListIssueComments", + "AddComment" + ]; +} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs index 53b2cfe..1249bd1 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs @@ -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? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) +public sealed class K8sConfigTools( + K8sClientProvider clientProvider, + IConfiguration configuration, + ToolRegistryService registry, + ILogger? 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); diff --git a/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs index 8d2b506..45582a8 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs @@ -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? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) +public sealed class K8sDeploymentTools( + K8sClientProvider clientProvider, + IConfiguration configuration, + ToolRegistryService registry, + ILogger? 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); diff --git a/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs index 870462d..a1fc4e5 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs @@ -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? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) +public sealed class K8sNetworkTools( + K8sClientProvider clientProvider, + IConfiguration configuration, + ToolRegistryService registry, + ILogger? logger = null) + : KubernetesToolsBase(clientProvider, configuration, registry, logger) { [McpServerTool, Description("Список service в namespace")] public async Task 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)) diff --git a/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs index a0fc12a..5817080 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs @@ -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? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) +public sealed class K8sPodsTools( + K8sClientProvider clientProvider, + IConfiguration configuration, + ToolRegistryService registry, + ILogger? 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)); diff --git a/LazyBear.MCP/Services/Kubernetes/KubernetesToolModule.cs b/LazyBear.MCP/Services/Kubernetes/KubernetesToolModule.cs new file mode 100644 index 0000000..7e5dc5a --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/KubernetesToolModule.cs @@ -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 ToolNames => + [ + // Pods + "ListPods", + "GetPodStatus", + "GetPodLogs", + // Deployments + "ListDeployments", + "ScaleDeployment", + "GetRolloutStatus", + "RestartDeployment", + // Network + "ListServices", + "GetServiceDetails", + "ListIngresses", + // Config + "ListConfigMaps", + "GetConfigMapData", + "ListSecrets", + "GetSecretKeys" + ]; +} diff --git a/LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs b/LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs index 896771e..884b6fd 100644 --- a/LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs +++ b/LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs @@ -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)); diff --git a/LazyBear.MCP/Services/Logging/InMemoryLogSink.cs b/LazyBear.MCP/Services/Logging/InMemoryLogSink.cs new file mode 100644 index 0000000..0174fcc --- /dev/null +++ b/LazyBear.MCP/Services/Logging/InMemoryLogSink.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace LazyBear.MCP.Services.Logging; + +/// +/// Singleton, хранящий последние записей лога в памяти. +/// Безопасен для многопоточной записи. +/// +public sealed class InMemoryLogSink +{ + public const int Capacity = 500; + + private readonly ConcurrentQueue _entries = new(); + + /// Событие вызывается при добавлении новой записи. + public event Action? OnLog; + + public void Add(LogEntry entry) + { + _entries.Enqueue(entry); + + // Обрезаем старые записи + while (_entries.Count > Capacity) + { + _entries.TryDequeue(out _); + } + + OnLog?.Invoke(entry); + } + + /// + /// Возвращает снимок всех записей. Опциональный фильтр по категории-префиксу. + /// + public IReadOnlyList 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(); +} diff --git a/LazyBear.MCP/Services/Logging/InMemoryLoggerProvider.cs b/LazyBear.MCP/Services/Logging/InMemoryLoggerProvider.cs new file mode 100644 index 0000000..94f136d --- /dev/null +++ b/LazyBear.MCP/Services/Logging/InMemoryLoggerProvider.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; + +namespace LazyBear.MCP.Services.Logging; + +/// +/// ILoggerProvider, направляющий все логи в . +/// +[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 state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) return; + + var message = formatter(state, exception); + var entry = new LogEntry( + DateTimeOffset.Now, + logLevel, + category, + message, + exception?.ToString()); + + sink.Add(entry); + } +} diff --git a/LazyBear.MCP/Services/Logging/LogEntry.cs b/LazyBear.MCP/Services/Logging/LogEntry.cs new file mode 100644 index 0000000..dccd3c2 --- /dev/null +++ b/LazyBear.MCP/Services/Logging/LogEntry.cs @@ -0,0 +1,14 @@ +namespace LazyBear.MCP.Services.Logging; + +public sealed record LogEntry( + DateTimeOffset Timestamp, + LogLevel Level, + string Category, + string Message, + string? Exception = null) +{ + /// Короткое имя категории (последний сегмент namespace) + public string ShortCategory => Category.Contains('.') + ? Category[(Category.LastIndexOf('.') + 1)..] + : Category; +} diff --git a/LazyBear.MCP/Services/ToolRegistry/IToolModule.cs b/LazyBear.MCP/Services/ToolRegistry/IToolModule.cs new file mode 100644 index 0000000..051b0b8 --- /dev/null +++ b/LazyBear.MCP/Services/ToolRegistry/IToolModule.cs @@ -0,0 +1,17 @@ +namespace LazyBear.MCP.Services.ToolRegistry; + +/// +/// Описывает группу MCP-инструментов (один интеграционный модуль). +/// Реализуйте этот интерфейс для регистрации новых модулей. +/// +public interface IToolModule +{ + /// Уникальное имя модуля (Jira, Kubernetes, Confluence, …) + string ModuleName { get; } + + /// Имена всех инструментов, входящих в модуль. + IReadOnlyList ToolNames { get; } + + /// Человекочитаемое описание модуля для TUI. + string Description { get; } +} diff --git a/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs b/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs new file mode 100644 index 0000000..49eafb3 --- /dev/null +++ b/LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs @@ -0,0 +1,96 @@ +using System.Collections.Concurrent; + +namespace LazyBear.MCP.Services.ToolRegistry; + +/// +/// Singleton. Хранит runtime-состояние включённости модулей и отдельных инструментов. +/// Thread-safe. Уведомляет подписчиков при любом изменении. +/// +public sealed class ToolRegistryService +{ + private readonly ConcurrentDictionary _moduleEnabled = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _toolEnabled = new(StringComparer.OrdinalIgnoreCase); + private readonly List _modules = []; + private readonly Lock _modulesLock = new(); + + /// Вызывается при любом изменении состояния. + 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 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}"; +} diff --git a/LazyBear.MCP/TUI/Components/App.razor b/LazyBear.MCP/TUI/Components/App.razor new file mode 100644 index 0000000..de24501 --- /dev/null +++ b/LazyBear.MCP/TUI/Components/App.razor @@ -0,0 +1,81 @@ +@using LazyBear.MCP.Services.Logging +@using LazyBear.MCP.Services.ToolRegistry +@inject ToolRegistryService Registry +@inject InMemoryLogSink LogSink + +@implements IDisposable + + + + + @* Таб-навигация *@ + + + + + + + @* Контент таба *@ + @if (_activeTab == Tab.Overview) + { + + } + else if (_activeTab == Tab.Logs) + { + + } + else + { + + } + + + + +@code { + private enum Tab { Overview, Logs, Settings } + private Tab _activeTab = Tab.Overview; + + protected override void OnInitialized() + { + Registry.StateChanged += OnStateChanged; + LogSink.OnLog += OnNewLog; + } + + private void SetTab(Tab tab) + { + _activeTab = tab; + StateHasChanged(); + } + + private void OnStateChanged() + { + InvokeAsync(StateHasChanged); + } + + private void OnNewLog(LogEntry _) + { + if (_activeTab == Tab.Logs) + { + InvokeAsync(StateHasChanged); + } + } + + public void Dispose() + { + Registry.StateChanged -= OnStateChanged; + LogSink.OnLog -= OnNewLog; + } +} diff --git a/LazyBear.MCP/TUI/Components/LogsTab.razor b/LazyBear.MCP/TUI/Components/LogsTab.razor new file mode 100644 index 0000000..5cc009d --- /dev/null +++ b/LazyBear.MCP/TUI/Components/LogsTab.razor @@ -0,0 +1,119 @@ +@using LazyBear.MCP.Services.Logging +@inject InMemoryLogSink LogSink + +@implements IDisposable + + + + + @* Фильтр по модулю *@ + + +