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

@@ -4,15 +4,17 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="RazorConsole.Core" Version="0.5.0" />
<PackageReference Include="RestSharp" Version="112.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>

View File

@@ -1,18 +1,39 @@
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<K8sClientProvider>();
builder.Services.AddSingleton<JiraClientProvider>();
builder.Services.AddSingleton<ConfluenceClientProvider>();
// ── ToolRegistry ─────────────────────────────────────────────────────────────
builder.Services.AddSingleton<ToolRegistryService>();
// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI)
builder.Services.AddSingleton<IToolModule, JiraToolModule>();
builder.Services.AddSingleton<IToolModule, KubernetesToolModule>();
builder.Services.AddSingleton<IToolModule, ConfluenceToolModule>();
// ── MCP-сервер ───────────────────────────────────────────────────────────────
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
// ── TUI как фоновый сервис ───────────────────────────────────────────────────
builder.Services.AddHostedService<TuiHostedService>();
var app = builder.Build();
app.MapMcp();

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}";
}

View File

@@ -0,0 +1,81 @@
@using LazyBear.MCP.Services.Logging
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows>
<Panel Title="LazyBear MCP" BorderColor="@Spectre.Console.Color.Gold1" Expand="true">
<Rows>
@* Таб-навигация *@
<Columns>
<TextButton Content="[1] Overview"
OnClick="@(() => SetTab(Tab.Overview))"
BackgroundColor="@(_activeTab == Tab.Overview ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="1" />
<TextButton Content="[2] Logs"
OnClick="@(() => SetTab(Tab.Logs))"
BackgroundColor="@(_activeTab == Tab.Logs ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="2" />
<TextButton Content="[3] Settings"
OnClick="@(() => SetTab(Tab.Settings))"
BackgroundColor="@(_activeTab == Tab.Settings ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="3" />
</Columns>
@* Контент таба *@
@if (_activeTab == Tab.Overview)
{
<OverviewTab />
}
else if (_activeTab == Tab.Logs)
{
<LogsTab />
}
else
{
<SettingsTab />
}
</Rows>
</Panel>
</Rows>
@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;
}
}

View File

@@ -0,0 +1,119 @@
@using LazyBear.MCP.Services.Logging
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows>
<Markup Content=" " />
@* Фильтр по модулю *@
<Columns>
<Markup Content="Filter: " />
<Select TItem="string"
Options="@_filterOptions"
Value="@_selectedFilter"
ValueChanged="@OnFilterChanged"
FocusOrder="10" />
</Columns>
<Markup Content=" " />
@{
var entries = GetFilteredEntries();
}
@if (entries.Count == 0)
{
<Markup Content="[grey]No log entries yet...[/]" />
}
else
{
@* Показываем последние 20 строк с прокруткой *@
<ViewHeightScrollable LinesToRender="20"
ScrollOffset="@_scrollOffset"
ScrollOffsetChanged="@(v => { _scrollOffset = v; })" >
<Rows>
@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;
<Columns>
<Markup Content="@($"[grey]{time}[/]")" />
<Markup Content="@($" {levelTag} ")" Foreground="@levelColor" />
<Markup Content="@($"[grey]{cat}[/]")" />
<Markup Content="@($" {msg}")" />
</Columns>
}
</Rows>
</ViewHeightScrollable>
}
</Rows>
@code {
private string _selectedFilter = "All";
private int _scrollOffset = 0;
private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"];
private static readonly Dictionary<string, string?> 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<LogEntry> 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;
}
}

View File

@@ -0,0 +1,39 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows>
<Markup Content=" " />
@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);
<Panel Title="@module.ModuleName"
BorderColor="@(isEnabled ? Spectre.Console.Color.Green3 : Spectre.Console.Color.Grey46)"
Expand="true">
<Columns>
<Rows>
<Columns>
<Markup Content="Status: " />
<Markup Content="@statusText" Foreground="@statusColor" />
</Columns>
<Columns>
<Markup Content="Tools: " />
<Markup Content="@($"{active}/{total} active")" Foreground="@activeColor" />
</Columns>
<Markup Content="@module.Description" Foreground="@Spectre.Console.Color.Grey" />
</Rows>
</Columns>
</Panel>
}
<Markup Content=" " />
<Markup Content="[grey]Go to Settings tab to toggle modules and tools[/]" />
</Rows>
@code {
}

View File

@@ -0,0 +1,42 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows>
<Markup Content=" " />
<Markup Content="[bold]Tool Registry — runtime enable/disable[/]" />
<Markup Content="[grey]Changes take effect immediately without restart[/]" />
<Markup Content=" " />
@{
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++;
<Panel Title="@module.ModuleName" BorderColor="@moduleColor" Expand="true">
<Rows>
<Columns>
<TextButton Content="@(moduleEnabled ? "[green]■ MODULE ENABLED[/]" : "[red]□ MODULE DISABLED[/]")"
OnClick="@(() => Registry.ToggleModule(moduleName))"
BackgroundColor="@(moduleEnabled ? Spectre.Console.Color.DarkGreen : Spectre.Console.Color.DarkRed)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@capturedFocus" />
<Markup Content="@($" {module.Description}")" Foreground="@Spectre.Console.Color.Grey" />
</Columns>
<Markup Content=" " />
<ToolButtonList Module="@module" StartFocusIdx="@focusIdx" />
</Rows>
</Panel>
focusIdx += module.ToolNames.Count;
<Markup Content=" " />
}
</Rows>
@code {
}

View File

@@ -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;
<TextButton Content="@(toolEnabled ? $"[green]✓[/] {toolName}" : $"[grey]✗[/] {toolName}")"
OnClick="@(() => Registry.ToggleTool(moduleName, toolName))"
BackgroundColor="@(toolEnabled ? Spectre.Console.Color.Grey19 : Spectre.Console.Color.Grey7)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@fo" />
}
@code {
[Parameter, EditorRequired] public IToolModule Module { get; set; } = null!;
[Parameter] public int StartFocusIdx { get; set; } = 100;
}

View File

@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Components
@using RazorConsole.Components
@using RazorConsole.Core
@using RazorConsole.Core.Rendering
@using LazyBear.MCP.TUI.Components

View File

@@ -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;
/// <summary>
/// Запускает RazorConsole TUI как IHostedService в отдельном потоке,
/// чтобы не блокировать ASP.NET Core pipeline.
/// </summary>
public sealed class TuiHostedService(IServiceProvider services, ILogger<TuiHostedService> logger) : IHostedService
{
private Thread? _tuiThread;
private CancellationTokenSource? _cts;
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
// Регистрируем все IToolModule-модули в ToolRegistryService
var registry = services.GetRequiredService<ToolRegistryService>();
foreach (var module in services.GetServices<IToolModule>())
{
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<App>(configure: configure =>
{
configure.ConfigureServices((_, svc) =>
{
// Пробрасываем ключевые Singleton из основного DI-контейнера в TUI-контейнер
svc.AddSingleton(services.GetRequiredService<ToolRegistryService>());
svc.AddSingleton(services.GetRequiredService<Services.Logging.InMemoryLogSink>());
});
})
.Build();
host.Run();
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка в потоке TUI");
}
}
}