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
+
+
+
+
+ @* Фильтр по модулю *@
+
+
+
+
+
+
+
+ @{
+ var entries = GetFilteredEntries();
+ }
+
+ @if (entries.Count == 0)
+ {
+
+ }
+ else
+ {
+ @* Показываем последние 20 строк с прокруткой *@
+
+
+ @foreach (var entry in entries)
+ {
+ var levelColor = entry.Level switch
+ {
+ LogLevel.Error or LogLevel.Critical => Spectre.Console.Color.Red,
+ LogLevel.Warning => Spectre.Console.Color.Yellow,
+ LogLevel.Information => Spectre.Console.Color.White,
+ _ => Spectre.Console.Color.Grey
+ };
+
+ var levelTag = entry.Level switch
+ {
+ LogLevel.Error => "ERR",
+ LogLevel.Critical => "CRT",
+ LogLevel.Warning => "WRN",
+ LogLevel.Information => "INF",
+ LogLevel.Debug => "DBG",
+ _ => "TRC"
+ };
+
+ var time = entry.Timestamp.ToString("HH:mm:ss");
+ var cat = entry.ShortCategory.Length > 18
+ ? entry.ShortCategory[..18]
+ : entry.ShortCategory.PadRight(18);
+ var msg = entry.Message.Length > 80
+ ? entry.Message[..80] + "..."
+ : entry.Message;
+
+
+
+
+
+
+
+ }
+
+
+ }
+
+
+@code {
+ private string _selectedFilter = "All";
+ private int _scrollOffset = 0;
+
+ private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"];
+
+ private static readonly Dictionary FilterPrefixes = new()
+ {
+ ["All"] = null,
+ ["Jira"] = "LazyBear.MCP.Services.Jira",
+ ["Kubernetes"] = "LazyBear.MCP.Services.Kubernetes",
+ ["Confluence"] = "LazyBear.MCP.Services.Confluence",
+ ["MCP"] = "ModelContextProtocol",
+ ["System"] = "Microsoft"
+ };
+
+ private IReadOnlyList GetFilteredEntries()
+ {
+ FilterPrefixes.TryGetValue(_selectedFilter, out var prefix);
+ return LogSink.GetEntries(prefix);
+ }
+
+ private void OnFilterChanged(string value)
+ {
+ _selectedFilter = value;
+ _scrollOffset = 0;
+ StateHasChanged();
+ }
+
+ protected override void OnInitialized()
+ {
+ LogSink.OnLog += HandleNewLog;
+ }
+
+ private void HandleNewLog(LogEntry _)
+ {
+ InvokeAsync(StateHasChanged);
+ }
+
+ public void Dispose()
+ {
+ LogSink.OnLog -= HandleNewLog;
+ }
+}
diff --git a/LazyBear.MCP/TUI/Components/OverviewTab.razor b/LazyBear.MCP/TUI/Components/OverviewTab.razor
new file mode 100644
index 0000000..d1f44b2
--- /dev/null
+++ b/LazyBear.MCP/TUI/Components/OverviewTab.razor
@@ -0,0 +1,39 @@
+@using LazyBear.MCP.Services.ToolRegistry
+@inject ToolRegistryService Registry
+
+
+
+ @foreach (var module in Registry.GetModules())
+ {
+ var (active, total) = Registry.GetToolCounts(module.ModuleName);
+ var isEnabled = Registry.IsModuleEnabled(module.ModuleName);
+ var statusColor = isEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red;
+ var statusText = isEnabled ? "ENABLED" : "DISABLED";
+ var activeColor = active == total
+ ? Spectre.Console.Color.Green
+ : (active == 0 ? Spectre.Console.Color.Red : Spectre.Console.Color.Yellow);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+
+@code {
+}
diff --git a/LazyBear.MCP/TUI/Components/SettingsTab.razor b/LazyBear.MCP/TUI/Components/SettingsTab.razor
new file mode 100644
index 0000000..c85aa3a
--- /dev/null
+++ b/LazyBear.MCP/TUI/Components/SettingsTab.razor
@@ -0,0 +1,42 @@
+@using LazyBear.MCP.Services.ToolRegistry
+@inject ToolRegistryService Registry
+
+
+
+
+
+
+
+ @{
+ int focusIdx = 20;
+ }
+
+ @foreach (var module in Registry.GetModules())
+ {
+ var moduleEnabled = Registry.IsModuleEnabled(module.ModuleName);
+ var moduleColor = moduleEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red;
+ var moduleName = module.ModuleName;
+ var capturedFocus = focusIdx++;
+
+
+
+
+
+
+
+
+
+
+
+
+ focusIdx += module.ToolNames.Count;
+
+ }
+
+
+@code {
+}
diff --git a/LazyBear.MCP/TUI/Components/ToolButtonList.razor b/LazyBear.MCP/TUI/Components/ToolButtonList.razor
new file mode 100644
index 0000000..38b47cb
--- /dev/null
+++ b/LazyBear.MCP/TUI/Components/ToolButtonList.razor
@@ -0,0 +1,21 @@
+@using LazyBear.MCP.Services.ToolRegistry
+@inject ToolRegistryService Registry
+
+@foreach (var (tool, idx) in Module.ToolNames.Select((t, i) => (t, i)))
+{
+ var toolEnabled = Registry.IsToolEnabled(Module.ModuleName, tool);
+ var toolName = tool;
+ var moduleName = Module.ModuleName;
+ var fo = StartFocusIdx + idx;
+
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IToolModule Module { get; set; } = null!;
+ [Parameter] public int StartFocusIdx { get; set; } = 100;
+}
diff --git a/LazyBear.MCP/TUI/Components/_Imports.razor b/LazyBear.MCP/TUI/Components/_Imports.razor
new file mode 100644
index 0000000..b86abbf
--- /dev/null
+++ b/LazyBear.MCP/TUI/Components/_Imports.razor
@@ -0,0 +1,5 @@
+@using Microsoft.AspNetCore.Components
+@using RazorConsole.Components
+@using RazorConsole.Core
+@using RazorConsole.Core.Rendering
+@using LazyBear.MCP.TUI.Components
diff --git a/LazyBear.MCP/TUI/TuiHostedService.cs b/LazyBear.MCP/TUI/TuiHostedService.cs
new file mode 100644
index 0000000..c624b4d
--- /dev/null
+++ b/LazyBear.MCP/TUI/TuiHostedService.cs
@@ -0,0 +1,69 @@
+using LazyBear.MCP.Services.ToolRegistry;
+using LazyBear.MCP.TUI.Components;
+using Microsoft.Extensions.Hosting;
+using RazorConsole.Core;
+
+namespace LazyBear.MCP.TUI;
+
+///
+/// Запускает RazorConsole TUI как IHostedService в отдельном потоке,
+/// чтобы не блокировать ASP.NET Core pipeline.
+///
+public sealed class TuiHostedService(IServiceProvider services, ILogger logger) : IHostedService
+{
+ private Thread? _tuiThread;
+ private CancellationTokenSource? _cts;
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _cts = new CancellationTokenSource();
+
+ // Регистрируем все IToolModule-модули в ToolRegistryService
+ var registry = services.GetRequiredService();
+ foreach (var module in services.GetServices())
+ {
+ registry.RegisterModule(module);
+ }
+
+ _tuiThread = new Thread(RunTui)
+ {
+ IsBackground = true,
+ Name = "RazorConsole-TUI"
+ };
+ _tuiThread.Start();
+
+ logger.LogInformation("TUI запущен в фоновом потоке");
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ _cts?.Cancel();
+ logger.LogInformation("TUI остановлен");
+ return Task.CompletedTask;
+ }
+
+ private void RunTui()
+ {
+ try
+ {
+ var host = Host.CreateDefaultBuilder()
+ .UseRazorConsole(configure: configure =>
+ {
+ configure.ConfigureServices((_, svc) =>
+ {
+ // Пробрасываем ключевые Singleton из основного DI-контейнера в TUI-контейнер
+ svc.AddSingleton(services.GetRequiredService());
+ svc.AddSingleton(services.GetRequiredService());
+ });
+ })
+ .Build();
+
+ host.Run();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Ошибка в потоке TUI");
+ }
+ }
+}