Compare commits

..

11 Commits

Author SHA1 Message Date
4f78606b2c Добавлена интеграция с Qdrant для поиска по векторам 2026-04-14 16:05:32 +03:00
b5fe2623b3 feat: добавить поддержку GitLab (api, clients, tools) и обновить документацию 2026-04-14 12:57:47 +03:00
e96bab114e Инициализировать Memory Bank: projectbrief, productContext, activeContext, systemPatterns, techContext, progress 2026-04-14 11:40:37 +03:00
d12e9873f0 TUI: переработать shell и адаптацию layout 2026-04-14 01:23:55 +03:00
7224a423fa update gitignore 2026-04-14 00:48:05 +03:00
454d2a2f40 Документация: обновить список возможностей 2026-04-14 00:02:45 +03:00
a7e912cac7 TUI: добавить endpoint в dashboard и выход по Q 2026-04-14 00:02:45 +03:00
01565b32d9 feat: добавить локализацию TUI (en/ru) с переключением клавишей L
- Добавлены TuiResources (sealed record), Locale, LocalizationService
- Все строки интерфейса вынесены из .razor-файлов в TuiResources
- App.razor: клавиша L циклически переключает локаль, заголовок показывает [EN]/[RU]
- Дочерние компоненты получают Loc как параметр (stateless)
- Создан AGENT.tui.md с правилами работы с TUI для агентов
- Обновлены AGENTS.md и CLAUDE.md со ссылками на AGENT.tui.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:53:59 +03:00
4819fbca6c chore: добавить .claude/ в .gitignore 2026-04-13 23:38:50 +03:00
9b27cd7dc2 fix: исправить навигацию клавиатуры в TUI через GlobalKeyboardService
- Добавить GlobalKeyboardService — выделенный поток с блокирующим
  Console.ReadKey, единственный источник клавишных событий для TUI
- Убрать FocusManager из App.razor: перехватывал Tab до компонентов
- Удалить @onkeydown с <Select>: RazorConsole не пробрасывает Tab/стрелки
  через этот механизм
- Использовать FocusedValue вместо Value в Select для корректной подсветки
- Обновить CLAUDE.md и AGENTS.md: архитектура TUI, RazorConsole gotchas
- Добавить docs/tui_log.md: разбор проблемы и справочник по RazorConsole

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:31:53 +03:00
4bf267d681 Добавить CLAUDE.md с описанием архитектуры и командами для разработки
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:57:48 +03:00
49 changed files with 6855 additions and 324 deletions

67
.clinerules Normal file
View File

@@ -0,0 +1,67 @@
# Cline's Memory Bank
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
## Memory Bank Structure
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
### Core Files (Required)
1. `projectbrief.md`
- Foundation document that shapes all other files
- Created at project start if it doesn't exist
- Defines core requirements and goals
- Source of truth for project scope
2. `productContext.md`
- Why this project exists
- Problems it solves
- How it should work
- User experience goals
3. `activeContext.md`
- Current work focus
- Recent changes
- Next steps
- Active decisions and considerations
- Important patterns and preferences
- Learnings and project insights
4. `systemPatterns.md`
- System architecture
- Key technical decisions
- Design patterns in use
- Component relationships
- Critical implementation paths
5. `techContext.md`
- Technologies used
- Development setup
- Technical constraints
- Dependencies
- Tool usage patterns
6. `progress.md`
- What works
- What's left to build
- Current status
- Known issues
- Evolution of project decisions
### Additional Context
Create additional files/folders within memory-bank/ when they help organize:
- Complex feature documentation
- Integration specifications
- API documentation
- Testing strategies
- Deployment procedures
## Documentation Updates
Memory Bank updates occur when:
1. Discovering new project patterns
2. After implementing significant changes
3. When user requests with **update memory bank** (MUST review ALL files)
4. When context needs clarification
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.

6
.gitignore vendored
View File

@@ -16,3 +16,9 @@ obj/
# Microsoft
PublishFiles/
temp
# Claude Code
.claude/
LazyBear.MCP/LazyBear.MCP.csproj
/artifacts

61
AGENT.tui.md Normal file
View File

@@ -0,0 +1,61 @@
## AGENT.tui.md
Read this file whenever you touch anything in `LazyBear.MCP/TUI/`.
### TUI Structure
- Entry point: `TUI/Components/App.razor` — owns all keyboard input and tab routing.
- Keyboard input: `TUI/GlobalKeyboardService.cs` — single source; dedicated blocking thread on `Console.ReadKey(intercept: true)`. Do not add other console readers.
- Localization: `TUI/Localization/``LocalizationService` singleton + `TuiResources` record with `En`/`Ru` static instances.
- Components: `OverviewTab`, `LogsTab`, `SettingsTab` — pure display; receive state as parameters, fire no keyboard events.
- Models: `TUI/Models/``OverviewRow`, `SettingsEntry`.
- Palette: `TUI/UiPalette.cs` — all colors. Do not hardcode `Spectre.Console.Color` values in components.
### Keyboard Rules
- All keys are handled in `App.razor.HandleKeyDown()`. Do not attach `@onkeydown` to `<Select>` or any other RazorConsole component — framework intercepts Tab and arrows before they reach component-level callbacks.
- `GlobalKeyboardService` fires `OnKeyPressed(ConsoleKeyInfo)`. `App.razor` converts via `ConvertKey()` and calls `HandleKeyDown()` inside `InvokeAsync`.
- Do not use `Console.KeyAvailable` polling — it acquires the console mutex on every call and causes rendering lag. Always use blocking `Console.ReadKey` in a dedicated thread.
- Keyboard shortcuts: `Tab`/`Shift+Tab` — switch tabs; Arrows — navigate list; `Space` — toggle; `Enter` — open/expand; `L` — cycle language.
### RazorConsole Gotchas (0.5.0)
- `@onkeydown` on `<Select>` is captured as `AdditionalAttributes` and not called for Tab/arrow keys.
- `FocusManager` intercepts Tab globally — do not call `FocusNextAsync()` in `OnAfterRenderAsync` unconditionally; it shifts focus on every re-render.
- No public global key-intercept API exists in 0.5.0.
- `Select.Value` = committed selection (updated on Enter). `Select.FocusedValue` = highlighted item during navigation. Use `FocusedValue` to reflect external state immediately.
- `StateHasChanged()` from a background thread must go through `InvokeAsync(() => { /* mutate state */ StateHasChanged(); })`.
- Every `StateHasChanged()` triggers a full terminal redraw. Batch log-driven re-renders to avoid visible lag.
### Localization Rules
- All UI strings live in `TUI/Localization/TuiResources.cs` only. No hardcoded strings in `.razor` files or other `.cs` files.
- To add a string: add a property to `TuiResources`, fill both `En` and `Ru` static instances. Build will fail if a `required init` is missing — by design.
- Log level names (`Info`, `Warn`, `Error`) stay in English in all locales — they are technical identifiers, not UI labels.
- Internal filter keys in `App.razor` (`"All"`, `"Info"`, `"Warn"`, `"Error"`) are English regardless of locale; `LogsTab` maps `"All"``Loc.FilterAll` for display.
- Language toggle: `L` key cycles through locales. `LocalizationService.SwitchNext()` fires `OnChanged`; `App.razor` re-renders via `OnLocaleChanged()`.
- Locale indicator shown in panel title: `"LazyBear MCP [EN]"` / `"LazyBear MCP [RU]"`.
- Do not inject `LocalizationService` into child tab components — `App.razor` passes the current `TuiResources` as `Loc` parameter. Child components are stateless regarding locale.
### Component Contract
- Child tab components (`OverviewTab`, `LogsTab`, `SettingsTab`) accept `[Parameter] TuiResources Loc` — always pass it from `App.razor`.
- Child components must not subscribe to events or inject services. Keep them as pure render components.
- `SelectedIndexChanged` callbacks exist for forward-compatibility; actual selection state is managed exclusively in `App.razor`.
### DI Registration Pattern
Services that must be both injectable and run as hosted services:
```csharp
services.AddSingleton<MyService>();
services.AddHostedService(sp => sp.GetRequiredService<MyService>());
```
`AddHostedService<T>()` alone creates a transient instance — unusable as injectable singleton.
### Working Rules
- Read `App.razor` before touching any keyboard or tab logic.
- Match the style of the file being edited.
- After changes, run `dotnet build`.
- Do not add new hardcoded strings to `.razor` files — add to `TuiResources` instead.
- Do not add new `Console.KeyAvailable` or `Console.ReadKey` calls outside `GlobalKeyboardService`.

View File

@@ -42,11 +42,15 @@
- Read related files before editing.
- Prefer minimal, non-breaking changes.
- Reuse existing patterns; avoid new abstractions without clear need.
- Match the style of the file being edited (naming, formatting, tone, language).
- Verify behavior against code and config, not `README.md`.
- After changes, run `dotnet build`. If MCP wiring changed, also run the inspector.
- Output in Russian. Keep code in English. Keep comments and commit messages in Russian.
- If the request is broad or underspecified, ask one short clarifying question first. Otherwise act on the best reasonable assumption.
- Project file structure and metadata indexed in memory via MCP memory system.
### OpenCode: Question First
- Follow `docs/opencode/question-policy.md` for the detailed `question` usage policy.
### Documentation
- **TUI work:** read `AGENT.tui.md` first — keyboard, localization, RazorConsole gotchas, component contract.
- RazorConsole gotchas and session notes: `docs/tui_log.md`.
- RazorConsole library docs: `docs/razorconsole/` (`overview.md`, `components.md`, `custom-translators.md`).
- OpenCode question policy: `docs/opencode/question-policy.md`.

85
CLAUDE.md Normal file
View File

@@ -0,0 +1,85 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Language Conventions
- **Output / explanations:** Russian
- **Code:** English
- **Comments, commit messages:** Russian
## Commands
```bash
# Build
dotnet build
# Run (starts TUI + HTTP MCP endpoint on port 5000)
dotnet run --project LazyBear.MCP
# Test MCP tool wiring (only needed after changing transport or tool registration)
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
```
There are **no test projects**. After making changes, run `dotnet build`. If MCP wiring changed (new tool, new module, transport changes), also run the inspector.
Port is controlled via the `ASPNETCORE_URLS` environment variable (default `http://localhost:5000`). Ignore `launchSettings.json` — it shows a different port that is not used.
## Architecture
LazyBear is a .NET 10 MCP server exposing Jira, Confluence, and Kubernetes integrations to AI clients. It runs in two simultaneous modes that share a single DI container:
1. **TUI (foreground)** — RazorConsole terminal UI (`App.razor`), owns the console, handles keyboard navigation
2. **HTTP MCP endpoint (background)**`McpWebHostedService` runs as a hosted service, serves MCP tool calls on port 5000
Both share the same singletons: `ToolRegistryService`, `InMemoryLogSink`, `GlobalKeyboardService`, and the three client providers.
### Plugin System (IToolModule)
Each integration implements `IToolModule` (in `Services/ToolRegistry/IToolModule.cs`) and declares its `ModuleName`, `ToolNames[]`, and `Description`. Tool classes are auto-discovered at startup via reflection using `WithToolsFromAssembly()` — classes are annotated with `[McpServerToolType]`, methods with `[McpServerTool]`. Registering a new module requires only: implement `IToolModule`, create tool classes with attributes, and add DI registration in `Program.cs`.
### ToolRegistryService
Singleton that tracks enabled/disabled state for both modules and individual tools at runtime (no restart needed). Uses `ConcurrentDictionary` for thread safety. Fires `StateChanged` event on toggles; TUI components subscribe to re-render. Tool keys use the format `"ModuleName::ToolName"`.
### Provider Pattern
Each client (`K8sClientProvider`, `JiraClientProvider`, `ConfluenceClientProvider`) is a lazy singleton. If initialization fails (missing config, unreachable endpoint), it captures an `InitializationError` string. **Tools return error strings instead of throwing exceptions** — this is intentional so MCP clients see the configuration issue gracefully.
### Logging
`InMemoryLogSink` maintains a 500-entry circular `ConcurrentQueue`. All .NET logs flow through `InMemoryLoggerProvider` → sink → `OnLog` event → TUI Logs tab live view.
### TUI Agent Reference
**Read `AGENT.tui.md` before making any changes to `TUI/`.** It covers keyboard handling, localization rules, RazorConsole gotchas, and component contract.
### TUI Keyboard Input
`GlobalKeyboardService` (`TUI/GlobalKeyboardService.cs`) is the **single source of keyboard events** for the entire TUI. It runs a dedicated background thread with a blocking `Console.ReadKey(intercept: true)` — no polling. `App.razor` subscribes to `OnKeyPressed`, converts `ConsoleKeyInfo` to `KeyboardEventArgs`, and dispatches via `InvokeAsync`.
Do **not** use `@onkeydown` on `<Select>` or other RazorConsole components for navigation logic — the framework intercepts Tab and arrow keys internally before they reach component-level callbacks. See `docs/tui_log.md` for the full breakdown.
### TUI Navigation
Tabs: Overview → Logs → Settings (switch with `Tab`/`Shift+Tab`). In Settings: arrow keys navigate the module→tool tree, `Space` toggles enable/disable, `Enter` expands/collapses. In Overview, `Enter` on a module jumps to its Settings entry.
## Configuration Gotchas
- **`Jira:Url`** is required; if missing, all Jira tools return string errors
- **K8s kubeconfig fallback order:** explicit `Kubernetes:KubeconfigPath``~/.kube/config` → in-cluster config
- **Source of truth:** `Program.cs`, not `README.md` (README is aspirational)
- `Pages/` directory exists but Razor Pages are **not enabled** in `Program.cs` — do not use them
- **RazorConsole keyboard gotchas:** `@onkeydown` on interactive components doesn't propagate Tab/arrows; `FocusManager` intercepts Tab globally; there is no public global key-intercept API. Full notes: `docs/tui_log.md`
- **`GlobalKeyboardService` registration pattern** — registered as both singleton and hosted service so it can be injected into Razor components: `services.AddSingleton<T>()` + `services.AddHostedService(sp => sp.GetRequiredService<T>())`
- **`Console.KeyAvailable` polling causes rendering lag** — it acquires the console mutex on every call and competes with RazorConsole's renderer. Always use blocking `Console.ReadKey` in a dedicated thread instead
## Key Dependencies
| Package | Role |
|---------|------|
| `ModelContextProtocol.AspNetCore` 1.2.0 | HTTP MCP transport |
| `KubernetesClient` 19.0.2 | K8s API |
| `RestSharp` 112.0.0 | Jira / Confluence HTTP |
| `RazorConsole.Core` 0.5.0 | Terminal UI framework |
| `Polly` 8.4.2 | Retry/resilience policies |

View File

@@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseAppHost>false</UseAppHost>
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
</PropertyGroup>
@@ -17,6 +18,9 @@
<PackageReference Include="RazorConsole.Core" Version="0.5.0" />
<PackageReference Include="RestSharp" Version="112.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<EmbeddedResource Include="logo.svg">
<LogicalView>LazyBear.MCP</LogicalView>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -1,41 +1,98 @@
using LazyBear.MCP.Services.Confluence;
using LazyBear.MCP.Services.GitLab;
using LazyBear.MCP.Services.Jira;
using LazyBear.MCP.Services.Kubernetes;
using LazyBear.MCP.Services.Logging;
using LazyBear.MCP.Services.Mcp;
using LazyBear.MCP.Services.Qdrant;
using LazyBear.MCP.Services.ToolRegistry;
using LazyBear.MCP.TUI;
using LazyBear.MCP.TUI.Components;
using LazyBear.MCP.TUI.Localization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RazorConsole.Core;
var builder = WebApplication.CreateBuilder(args);
// ── InMemoryLogSink: регистрируем Singleton и кастомный логгер ───────────────
// ── Общий логгер и один DI-контейнер для TUI + MCP ──────────────────────────
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>();
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddSingleton(logSink);
services.AddSingleton<ToolRegistryService>();
// ── ToolRegistry ─────────────────────────────────────────────────────────────
builder.Services.AddSingleton<ToolRegistryService>();
// MCP-провайдеры
services.AddSingleton<K8sClientProvider>();
services.AddSingleton<JiraClientProvider>();
services.AddSingleton<ConfluenceClientProvider>();
services.AddSingleton<GitLabClientProvider>();
services.AddSingleton<QdrantClientProvider>();
// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI)
builder.Services.AddSingleton<IToolModule, JiraToolModule>();
builder.Services.AddSingleton<IToolModule, KubernetesToolModule>();
builder.Services.AddSingleton<IToolModule, ConfluenceToolModule>();
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
services.AddSingleton<IToolModule, JiraToolModule>();
services.AddSingleton<IToolModule, KubernetesToolModule>();
services.AddSingleton<IToolModule, ConfluenceToolModule>();
services.AddSingleton<IToolModule, GitLabToolModule>();
services.AddSingleton<IToolModule, QdrantToolModule>();
// ── MCP-сервер ───────────────────────────────────────────────────────────────
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
services.AddHostedService<McpWebHostedService>();
// ── TUI как фоновый сервис ───────────────────────────────────────────────────
builder.Services.AddHostedService<TuiHostedService>();
// Глобальный читатель клавиш — единственный источник клавишных событий для TUI
services.AddSingleton<GlobalKeyboardService>();
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
var app = builder.Build();
// Локализация TUI (en/ru, переключение клавишей L)
services.AddSingleton<LocalizationService>();
})
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddProvider(new InMemoryLoggerProvider(logSink));
})
.UseRazorConsole<App>(hostBuilder =>
{
hostBuilder.ConfigureServices(services =>
{
services.Configure<ConsoleAppOptions>(options =>
{
options.AutoClearConsole = true;
options.EnableTerminalResizing = true;
options.AfterRenderAsync = (_, _, _) =>
{
try
{
Console.CursorVisible = false;
}
catch
{
// Ignore terminals that do not support CursorVisible.
}
app.MapMcp();
try
{
Console.Write("\u001b[?25l");
Console.Out.Flush();
}
catch
{
// Ignore terminals that do not support ANSI cursor control.
}
var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000";
app.Run(urls);
return Task.CompletedTask;
};
});
});
})
.Build();
// ── Регистрируем модули один раз до старта TUI и web host ───────────────────
var registry = host.Services.GetRequiredService<ToolRegistryService>();
foreach (var module in host.Services.GetServices<IToolModule>())
{
registry.RegisterModule(module);
}
await host.RunAsync();

469
LazyBear.MCP/README.md Normal file
View File

@@ -0,0 +1,469 @@
# LazyBear MCP Server
![LazyBear Logo](logo.png)
**.NET 10 сервер Model Context Protocol (MCP) для интеграции с Jira, Confluence, Kubernetes и GitLab.**
---
## ✨ Возможности
| Модуль | Описание | Статус |
|--------|----------|--------|
| 📋 **Jira** | Работа с задачами, JQL, комментариями | ✅ Доступно |
| 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно |
| ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно |
| 🌳 **GitLab** | Работа с репозиториями, MR, Issue, ветками, тегами | ✅ Доступно |
| 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно |
| 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно |
---
## 🏗️ Архитектура
```
┌───────────────────────────────────────────────────────────┐
│ HTTP Transport Layer (ASP.NET Core) │
│ └── ModelContextProtocol 1.2.0 HTTP транспорт │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ Application Layer (Razor Pages UI) │
│ └── Web-страницы для мониторинга K8s │
└───────────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
│ Integration Layers │
│ ├── Jira API (REST) │
│ ├── Confluence API (REST) │
│ ├── Kubernetes API (REST) │
│ └── GitLab API (REST) │
└───────────────────────────────────────────────────────────┘
```
**Потоки данных:**
1. **Initialize Flow**: Клиент → HTTP → Application → Integration Client → API
2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s/GitLab] → Возврат результата
3. **Health Check Flow**: `/health` → HTTP → Liveness probe → Status
---
## 🚀 Быстрый старт
### Требования
- .NET 10 SDK
- Kubectl и kubeconfig
- GitLab Personal Access Token (опционально)
- Docker Desktop (опционально)
### Запуск
```bash
cd LazyBear.MCP
dotnet run
```
Сервер запустится на `http://localhost:5000`
### Docker
```bash
docker build -t lazybear-mcp .
docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp
```
---
## 📦 Основные модули MCP
### 📋 Jira
Работа с Jira Issues и JQL запросами.
**Методы:**
- `createIssue` Создать новый тикет
- `updateIssue` Обновить существующий тикет
- `getIssueDetails` Получить детали тикета
- `searchIssues` Поиск тикетов по JQL
- `addComment` Добавить комментарий
- `getIssueStatuses` Получение доступных переходов статуса
- `listIssueComments` Список комментариев задачи
**Пример вызова:**
```json
{
"method": "jiraTools/createIssue",
"params": {
"projectKey": "LAZYBEAR",
"summary": "Fix memory leak in K8s deployment",
"description": "Memory leak detected in pod nginx-pod-abc123",
"type": "BUG",
"priority": "High",
"assignee": "dev@example.com"
}
}
```
---
### 📄 Confluence
Работа с Confluence страницами и пространствами.
**Методы:**
- `createPage` Создать новую страницу
- `updatePage` Обновить существующую страницу
- `deletePage` Удалить страницу
- `getPageContent` Получить содержимое страницы
- `searchPages` Поиск страниц по ключевым словам
- `getSpace` Получить информацию о пространстве
- `movePage` Переместить страницу
**Пример вызова:**
```json
{
"method": "confluenceTools/createPage",
"params": {
"spaceKey": "LAZYBEAR",
"title": "Инструкция по развёртыванию",
"body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий.
---
### ☸️ Kubernetes
Управление K8s кластером.
**Методы:**
**Конфигурация:**
- `readConfig` Чтение конфигурации кластера
- `writeConfig` Обновление конфигурации
- `deleteConfig` Удаление конфигурации
**Деплои:**
- `createDeployment` Создать деплой
- `updateDeployment` Обновить деплой
- `deleteDeployment` Удалить деплой
- `scaleDeployment` Масштабировать деплой
**Сети:**
- `createService` Создать сервис
- `updateService` Обновить сервис
- `deleteService` Удалить сервис
- `createIngress` Создать ingress
- `deleteIngress` Удалить ingress
**Поды:**
- `getPodStatus` Получить статус пода
- `restartPod` Перезапустить под
- `execIntoPod` Выполнить команду в поде
- `deletePod` Удалить под
**Примеры вызова:**
```json
{
"method": "k8sDeploymentTools/createDeployment",
"params": {
"name": "nginx",
"replicas": 3,
"image": "nginx:latest"
}
}
```
---
### 🌳 GitLab
Работа с GitLab API для управления репозиториями, MR, Issue, ветками и тегами.
**Методы:**
**Репозитории:**
- `list_projects` Список всех репозиториев
- `get_project` Информация о репозитории по ID/path
**Теги (Версии):**
- `list_versions` Список тегов репозитория
- `create_version` Создание нового тега
- `delete_version` Удаление тега
**Merge Requests:**
- `list_merge_requests` Список всех MR
- `get_merge_request` Информация о конкретном MR
- `create_merge_request` Создание MR
- `close_merge_request` Закрытие MR
- `open_merge_request` Открытие MR
- `list_merge_request_notes` Замечания к MR
- `create_merge_request_note` Добавление замечания
- `delete_merge_request_note` Удаление замечания
**Issues:**
- `list_issues` Список Issues
- `list_issues_simple` Быстрый список Issues
- `get_issue` Информация об Issue
- `create_issue` Создание Issue
- `update_issue` Обновление Issue
- `close_issue` Закрытие Issue
- `open_issue` Открытие Issue
- `list_issue_notes` Замечания к Issue
- `create_issue_note` Добавление замечания
- `delete_issue_note` Удаление замечания
**Ветки:**
- `list_branches` Список веток
- `get_branch` Информация о ветке
- `create_branch` Создание ветки
- `delete_branch` Удаление ветки
- `protect_branch` Защита ветки
- `unprotect_branch` Удаление защиты
**Примеры вызова:**
```json
{
"method": "gitlabTools/list_projects",
"params": {}
}
```
```json
{
"method": "gitlabTools/create_merge_request",
"params": {
"sourceBranch": "feature-xyz",
"targetBranch": "main",
"title": "Add new feature",
"description": "Implements new feature xyz"
}
}
```
```json
{
"method": "gitlabTools/create_issue",
"params": {
"title": "Fix production bug",
"description": "Critical bug in production environment",
"assigneeId": 123,
"labels": ["bug", "critical"]
}
}
```
---
## 📁 Структура проекта
```
LazyBear.MCP/
├── Program.cs # HTTP transport MCP сервер
├── Pages/ # Razor Pages UI
│ ├── Index.cshtml # Главная страница
│ └── Shared/ # Общие компоненты
├── Services/
│ ├── Jira/
│ │ └── JiraIssueTools.cs # Инструменты для Jira
│ ├── Confluence/
│ │ └── ConfluencePagesTools.cs # Инструменты для Confluence
│ ├── Kubernetes/
│ │ ├── K8sConfigTools.cs # Инструменты конфигурации
│ │ ├── K8sDeploymentTools.cs # Инструменты деплоя
│ │ ├── K8sNetworkTools.cs # Инструменты сети
│ │ ├── K8sPodsTools.cs # Инструменты подов
│ │ ├── K8sClientFactory.cs # Factory для клиентов
│ │ └── K8sClientProvider.cs # Provider для клиентов
│ └── GitLab/
│ ├── GitLabToolModule.cs # Регистрация инструментов
│ ├── GitLabToolsBase.cs # Базовый класс с common-методами
│ ├── GitLabApiClient.cs # REST клиент (RestSharp)
│ ├── GitLabClientProvider.cs # Provider
│ ├── GitLabClientFactory.cs # Factory
│ ├── GitLabRepositoryTools.cs # Репозитории
│ ├── GitLabVersionTools.cs # Теги
│ ├── GitLabMergeRequestTools.cs # MR
│ ├── GitLabIssueTools.cs # Issues
│ └── GitLabBranchTools.cs # Ветки
├── appsettings.json # Конфиг
└── global.json # Пин SDK
```
---
## 🖥️ Интерактивная панель
```
┌───────────────────────────────────────────┐
│ Dashboard: Обзор состояния кластера │
├───────────────────────────────────────────┤
│ Logs & Events: Журналы событий │
│ Containers & Images: Контейнеры │
│ Workloads & Nodes: Распределение │
└───────────────────────────────────────────┘
```
**Настройка в appsettings.json:**
```json
{
"Kubernetes": {
"KubeconfigPath": "~/.kube/config",
"DefaultNamespace": "default"
},
"Jira": {
"Url": "https://jira.example.com",
"Token": "your_jira_token",
"Project": ""
},
"Confluence": {
"Url": "https://confluence.example.com",
"Token": "your_confluence_token"
},
"GitLab": {
"Url": "https://gitlab.com",
"Token": "your_gitlab_pat",
"Project": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
"ModelContextProtocol": "Debug"
}
}
}
```
---
## 🔌 Интеграция
### Codex (Windows)
Файл: `.mcp.json`
```json
{
"mcpServers": {
"lazybear": {
"command": "dotnet",
"args": ["run", "--project", "E:\\Codex\\LazyBearWorks\\LazyBear.MCP"]
}
}
}
```
### Continue (VS Code)
Файл: `.vscode/continue/config.json`
```json
{
"mcpServers": {
"lazybear": {
"command": "dotnet",
"args": ["run", "--project", "${workspaceFolder}/LazyBear.MCP"],
"type": "stdio"
}
}
}
```
### OpenCode (Linux/Mac)
Файл: `~/.opencode/.mcp.json`
```json
{
"mcpServers": {
"lazybear": {
"command": "dotnet",
"args": ["run", "--project", "~/LazyBearWorks/LazyBear.MCP"]
}
}
}
```
### MCP Inspector
```bash
npm install -g @modelcontextprotocol/inspector
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
```
---
## 🔧 CLI тестирование
```bash
# Прямое тестирование через stdin
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0"}}}' | dotnet run --project LazyBear.MCP
```
---
## 🛠️ Разработка
### Сборка
```bash
dotnet build
```
### Запуск
```bash
dotnet run
```
### Тестирование
```bash
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
```
---
## 📦 Stack
- **Язык:** C#
- **Framework:** .NET 10
- **Framework Web:** ASP.NET Core 9
- **UI:** Razor Pages
- **Protocol:** Model Context Protocol (MCP)
- **API Clients:** RestSharp (для Jira, Confluence, GitLab), Kubernetes Client (для K8s)
**Документация:**
- **Сгенерированный API**: `/swagger` — Swagger UI
- **Метаданные методов**: MCP Tools — авт. описание от `Summary/Description`
### OpenAPI/Swagger
**Включите для просмотра API:**
```xml
<!-- LazyBear.MCP/Program.cs -->
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
var config = new OpenApiInfo { Title = "LazyBear MCP Server", Version = "1.0.0" };
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", config));
```
---
## 📚 Ссылки
- [GitLab API Documentation](https://docs.gitlab.com/ee/api/)
- [MCP Specification](https://modelcontextprotocol.io)
---
*Встроенная документация по MCP*

View File

@@ -0,0 +1,75 @@
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
/// <summary>
/// Обертка над RestSharp RestClient для GitLab API
/// </summary>
public sealed class GitLabApiClient : IDisposable
{
public RestClient RestClient { get; }
/// <summary>
/// Конструктор
/// </summary>
/// <param name="url">URL GitLab</param>
public GitLabApiClient(string url)
{
_restClient = new RestClient(url);
}
private readonly RestClient _restClient;
/// <summary>
/// Создание запроса GET
/// </summary>
public RestRequest GetRequest(string resource)
{
var request = new RestRequest(resource, Method.Get);
return request;
}
/// <summary>
/// Создание запроса POST
/// </summary>
public RestRequest PostRequest(string resource)
{
var request = new RestRequest(resource, Method.Post);
return request;
}
/// <summary>
/// Создание запроса PUT
/// </summary>
public RestRequest PutRequest(string resource)
{
var request = new RestRequest(resource, Method.Put);
return request;
}
/// <summary>
/// Создание запроса DELETE
/// </summary>
public RestRequest DeleteRequest(string resource)
{
var request = new RestRequest(resource, Method.Delete);
return request;
}
/// <summary>
/// Выполнение запроса
/// </summary>
public async System.Threading.Tasks.Task<RestResponse> ExecuteAsync(RestRequest request, System.Threading.CancellationToken? cancellationToken = null)
{
if (cancellationToken.HasValue)
{
return await _restClient.ExecuteAsync(request, cancellationToken.Value);
}
return await _restClient.ExecuteAsync(request);
}
public void Dispose()
{
_restClient.Dispose();
}
}

View File

@@ -0,0 +1,358 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
public sealed class GitLabBranchTools(
GitLabClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly string _token = configuration["GitLab:Token"] ?? string.Empty;
private const string ModuleName = "GitLab";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
private bool TryGetClient(out RestSharp.IRestClient client, out string error)
{
if (provider.InitializationError is not null)
{
client = null!;
error = $"GitLab клиент не инициализирован. Детали: {provider.InitializationError}";
return false;
}
var clientInstance = provider.GetClient();
if (clientInstance is null)
{
client = null!;
error = "GitLab клиент не создан.";
return false;
}
client = clientInstance.RestClient;
error = string.Empty;
return true;
}
private string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
[McpServerTool, Description("Получить список веток GitLab проекта")]
public async Task<string> ListBranches(
[Description("ID проекта")] int projectId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListBranches", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/repository/branches", Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "100");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_branches", response, $"/projects/{projectId}/repository/branches");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() == 0)
{
return $"Ветки в проекте #{projectId} не найдены.";
}
var lines = new List<string>();
foreach (var branch in root.EnumerateArray())
{
var name = GetNestedString(branch, "name") ?? "-";
var isDefault = GetNestedString(branch, "default") ?? "false";
var isProtected = GetNestedString(branch, "protected") ?? "false";
var commit = GetNestedString(branch, "commit", "short_id") ?? GetNestedString(branch, "commit", "id") ?? "-";
lines.Add($"{name} (default={isDefault}, protected={isProtected}, commit={commit})");
}
return $"Ветки проекта #{projectId} ({root.GetArrayLength()} шт.):{Environment.NewLine}{string.Join(Environment.NewLine, lines)}";
}
catch (Exception ex)
{
return FormatException("list_branches", ex);
}
}
[McpServerTool, Description("Получить ветку GitLab проекта")]
public async Task<string> GetBranch(
[Description("ID проекта")] int projectId,
[Description("Имя ветки")] string branchName,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetBranch", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(branchName))
{
return "Имя ветки GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var encoded = Uri.EscapeDataString(branchName);
var request = new RestRequest($"/projects/{projectId}/repository/branches/{encoded}", Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_branch", response, $"/projects/{projectId}/repository/branches/{encoded}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var name = GetNestedString(root, "name") ?? branchName;
var isDefault = GetNestedString(root, "default") ?? "false";
var isProtected = GetNestedString(root, "protected") ?? "false";
var canPush = GetNestedString(root, "can_push") ?? "false";
var commitId = GetNestedString(root, "commit", "id") ?? "-";
return $"Ветка '{name}' проекта #{projectId}:{Environment.NewLine}default={isDefault}, protected={isProtected}, can_push={canPush}{Environment.NewLine}commit={commitId}";
}
catch (Exception ex)
{
return FormatException("get_branch", ex);
}
}
[McpServerTool, Description("Создать ветку GitLab проекта")]
public async Task<string> CreateBranch(
[Description("ID проекта")] int projectId,
[Description("Имя новой ветки")] string branchName,
[Description("Ветка или SHA-реф источника")] string @ref,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateBranch", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(branchName))
{
return "Имя новой ветки GitLab не может быть пустым.";
}
if (string.IsNullOrWhiteSpace(@ref))
{
return "Источник ветки (ref) GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/repository/branches", Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
request.AddJsonBody(new { branch = branchName, @ref });
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_branch", response, $"/projects/{projectId}/repository/branches");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var name = GetNestedString(root, "name") ?? branchName;
var commit = GetNestedString(root, "commit", "short_id") ?? GetNestedString(root, "commit", "id") ?? "-";
return $"Ветка '{name}' успешно создана в проекте #{projectId}. commit={commit}";
}
catch (Exception ex)
{
return FormatException("create_branch", ex);
}
}
[McpServerTool, Description("Удалить ветку GitLab проекта")]
public async Task<string> DeleteBranch(
[Description("ID проекта")] int projectId,
[Description("Имя ветки")] string branchName,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeleteBranch", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(branchName))
{
return "Имя ветки GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var encoded = Uri.EscapeDataString(branchName);
var request = new RestRequest($"/projects/{projectId}/repository/branches/{encoded}", Method.Delete);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("delete_branch", response, $"/projects/{projectId}/repository/branches/{encoded}");
}
return $"Ветка '{branchName}' успешно удалена из проекта #{projectId}.";
}
catch (Exception ex)
{
return FormatException("delete_branch", ex);
}
}
[McpServerTool, Description("Защитить ветку GitLab проекта")]
public async Task<string> ProtectBranch(
[Description("ID проекта")] int projectId,
[Description("Имя ветки")] string branchName,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ProtectBranch", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(branchName))
{
return "Имя ветки GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/protected_branches", Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
request.AddJsonBody(new { name = branchName });
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("protect_branch", response, $"/projects/{projectId}/protected_branches");
}
return $"Ветка '{branchName}' успешно защищена в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("protect_branch", ex);
}
}
[McpServerTool, Description("Снять защиту с ветки GitLab проекта")]
public async Task<string> UnprotectBranch(
[Description("ID проекта")] int projectId,
[Description("Имя ветки")] string branchName,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UnprotectBranch", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(branchName))
{
return "Имя ветки GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var encoded = Uri.EscapeDataString(branchName);
var request = new RestRequest($"/projects/{projectId}/protected_branches/{encoded}", Method.Delete);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("unprotect_branch", response, $"/projects/{projectId}/protected_branches/{encoded}");
}
return $"Защита с ветки '{branchName}' успешно снята в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("unprotect_branch", ex);
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
/// <summary>
/// Фабрика клиента RestSharp для GitLab API
/// </summary>
public static class GitLabClientFactory
{
private static readonly TimeSpan[] BackoffDurations =
{
TimeSpan.FromMilliseconds(1000),
TimeSpan.FromMilliseconds(2000),
TimeSpan.FromMilliseconds(4000)
};
/// <summary>
/// Создание клиента RestSharp для GitLab API
/// </summary>
/// <param name="configuration">Конфигурация из DI</param>
/// <returns>Client или null при ошибке инициализации</returns>
public static RestClient? CreateClient(IConfiguration configuration)
{
var gitlabUrl = configuration["GitLab:Url"] ?? string.Empty;
if (string.IsNullOrWhiteSpace(gitlabUrl))
{
return null;
}
var config = new RestClientOptions(gitlabUrl)
{
UserAgent = "LazyBear-GitLab-MCP",
Timeout = TimeSpan.FromMilliseconds(30000)
};
return new RestClient(config);
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.Configuration;
namespace LazyBear.MCP.Services.GitLab;
/// <summary>
/// Провайдер GitLab клиента для DI
/// </summary>
public sealed class GitLabClientProvider : IDisposable
{
private readonly IConfiguration _config;
private readonly object _locker;
private GitLabApiClient? _client;
public string? InitializationError { get; private set; }
/// <summary>
/// Конструктор
/// </summary>
/// <param name="config">Конфигурация приложения</param>
public GitLabClientProvider(IConfiguration config)
{
_config = config;
_locker = new object();
}
private void SetError(string message)
{
InitializationError = message;
}
/// <summary>
/// Создание клиента
/// </summary>
public GitLabApiClient? GetClient()
{
var baseUrl = _config["GitLab:Url"];
if (string.IsNullOrEmpty(baseUrl))
{
SetError("GitLab:Url не настроен в конфигурации.");
return null;
}
lock (_locker)
{
if (_client == null)
{
_client = new GitLabApiClient(baseUrl);
}
return _client;
}
}
public void Dispose()
{
_client?.Dispose();
}
}

View File

@@ -0,0 +1,753 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
public sealed class GitLabIssueTools(
GitLabClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly string _token = configuration["GitLab:Token"] ?? string.Empty;
private readonly string _baseUrl = configuration["GitLab:Url"] ?? string.Empty;
private const string ModuleName = "GitLab";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
private bool TryGetClient(out RestSharp.IRestClient client, out string error)
{
if (provider.InitializationError is not null)
{
client = null!;
error = $"GitLab клиент не инициализирован. Детали: {provider.InitializationError}";
return false;
}
var clientInstance = provider.GetClient();
if (clientInstance is null)
{
client = null!;
error = "GitLab клиент не создан.";
return false;
}
client = clientInstance.RestClient;
error = string.Empty;
return true;
}
private string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
if (path.Length == 0) return null;
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
private static int GetNestedInt(JsonElement element, params string[] path)
{
var raw = GetNestedString(element, path);
return int.TryParse(raw, out var value) ? value : 0;
}
private static string GetIid(int iid) => iid > 0 ? $"#{iid}" : "-";
/// <summary>
/// Получить список Issues
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueState">Состояние issue (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить список Issues проекта")]
public async Task<string> ListIssues(
[Description("ID проекта")] int projectId,
[Description("Состояние issue")] string? issueState = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssues", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "30");
if (!string.IsNullOrWhiteSpace(issueState))
{
request.AddQueryParameter("state", issueState);
}
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_issues", response, $"/projects/{projectId}/issues");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0)
{
return $"Issues в проекте #{projectId} не найдены.";
}
var lines = new List<string>();
foreach (var issue in issuesElement.EnumerateArray())
{
var iid = GetIid(GetNestedInt(issue, "iid"));
var title = GetNestedString(issue, "title") ?? "-";
var state = GetNestedString(issue, "state") ?? "-";
var created_at = GetNestedString(issue, "created_at") ?? "-";
var author = GetNestedString(issue, "author", "name") ?? "-";
var labels = GetNestedString(issue, "labels") ?? "-";
var labelsList = GetNestedString(issue, "labels") is string labelsStr && !string.IsNullOrWhiteSpace(labelsStr)
? labelsStr.Split(',').Select(l => $"[{l.Trim()}]").ToArray() ?? new[] { labelsStr }
: Array.Empty<string>();
lines.Add($"{iid} - {title} [{state}]\n author: {author}\n labels: {string.Join(", ", labelsList)}");
lines.Add($" created_at: {created_at}");
}
return $"Issues проекта #{projectId} ({issuesElement.GetArrayLength()} шт.):\n{string.Join(Environment.NewLine, lines)}";
}
catch (Exception ex)
{
return FormatException("list_issues", ex);
}
}
/// <summary>
/// Получить список Issues без фильтрации по state
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить список Issues проекта (без фильтра state)")]
public async Task<string> ListIssuesSimple(
[Description("ID проекта")] int projectId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssuesSimple", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "30");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_issues_simple", response, $"/projects/{projectId}/issues");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0)
{
return $"Issues в проекте #{projectId} не найдены.";
}
var lines = new List<string>();
foreach (var issue in issuesElement.EnumerateArray())
{
var iid = GetIid(GetNestedInt(issue, "iid"));
var title = GetNestedString(issue, "title") ?? "-";
var state = GetNestedString(issue, "state") ?? "-";
var author = GetNestedString(issue, "author", "name") ?? "-";
var description = GetNestedString(issue, "description") ?? "-";
lines.Add($"{iid} - {title} [{state}]\n author: {author}\n description: {description}");
}
return $"Issues проекта #{projectId} ({issuesElement.GetArrayLength()} шт.):\n{string.Join(Environment.NewLine, lines)}";
}
catch (Exception ex)
{
return FormatException("list_issues_simple", ex);
}
}
/// <summary>
/// Получить конкретный Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить конкретный Issue")]
public async Task<string> GetIssue(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetIssue", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_issue", response, $"/projects/{projectId}/issues/{issueIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var iid = GetIid(GetNestedInt(root, "iid"));
var title = GetNestedString(root, "title") ?? "-";
var state = GetNestedString(root, "state") ?? "-";
var author = GetNestedString(root, "author", "name") ?? "-";
var created_at = GetNestedString(root, "created_at") ?? "-";
var labels = GetNestedString(root, "labels") ?? "-";
var description = GetNestedString(root, "description") ?? "-";
var labelsList = GetNestedString(root, "labels") is string labelsStr && !string.IsNullOrWhiteSpace(labelsStr)
? labelsStr.Split(',').Select(l => $"[{l.Trim()}]").ToArray() ?? new[] { labelsStr }
: Array.Empty<string>();
return $"Issue #{iid} в проекте #{projectId}:\n{title} [{state}]\n author: {author}\n labels: {string.Join(", ", labelsList)}\n created_at: {created_at}\n description: {description}";
}
catch (Exception ex)
{
return FormatException("get_issue", ex, $"/projects/{projectId}/issues/{issueIid}");
}
}
/// <summary>
/// Создать Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="title">Заголовок Issue</param>
/// <param name="description">Описание (опционально)</param>
/// <param name="labels">Метки (опционально)</param>
/// <param name="assigneeId">ID назначаемого пользователя (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Создать Issue")]
public async Task<string> CreateIssue(
[Description("ID проекта")] int projectId,
[Description("Заголовок Issue")] string title,
[Description("Описание Issue")] string? description = null,
[Description("Метки Issue")] string? labels = null,
[Description("ID назначаемого пользователя")] int? assigneeId = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateIssue", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(title))
{
return "Заголовок Issue GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues", RestSharp.Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var payload = new Dictionary<string, object?>
{
["title"] = title,
["description"] = description ?? string.Empty
};
if (!string.IsNullOrWhiteSpace(labels))
{
payload["labels"] = labels;
}
if (assigneeId.HasValue)
{
payload["assignee_id"] = assigneeId.Value;
}
request.AddJsonBody(payload);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_issue", response, $"/projects/{projectId}/issues");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var iid = GetIid(GetNestedInt(root, "iid"));
var issueTitle = GetNestedString(root, "title") ?? "-";
var issueState = GetNestedString(root, "state") ?? "-";
return $"Issue успешно создан в проекте #{projectId}:\nID: {iid}\n{issueTitle} [{issueState}]";
}
catch (Exception ex)
{
return FormatException("create_issue", ex);
}
}
/// <summary>
/// Обновить Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="subject">Новый заголовок (опционально)</param>
/// <param name="description">Новое описание (опционально)</param>
/// <param name="labels">Новые метки (опционально)</param>
/// <param name="state">Новое состояние (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Обновить Issue")]
public async Task<string> UpdateIssue(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
[Description("Новый заголовок")] string? subject = null,
[Description("Новое описание")] string? description = null,
[Description("Новые метки")] string? labels = null,
[Description("Новое состояние")] string? state = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpdateIssue", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Put);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var payload = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(subject))
{
payload["title"] = subject;
}
if (!string.IsNullOrWhiteSpace(description))
{
payload["description"] = description;
}
if (!string.IsNullOrWhiteSpace(labels))
{
payload["labels"] = labels;
}
if (!string.IsNullOrWhiteSpace(state))
{
payload["state_event"] = state;
}
request.AddJsonBody(payload);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("update_issue", response, $"/projects/{projectId}/issues/{issueIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var iid = GetIid(GetNestedInt(root, "iid"));
var issueTitle = GetNestedString(root, "title") ?? "-";
var issueState = GetNestedString(root, "state") ?? "-";
return $"Issue #{iid} ({issueTitle}) успешно обновлён в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("update_issue", ex, $"/projects/{projectId}/issues/{issueIid}");
}
}
/// <summary>
/// Закрыть Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Закрыть Issue")]
public async Task<string> CloseIssue(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CloseIssue", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Put);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
request.AddJsonBody(new { state_event = "close" });
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("close_issue", response, $"/projects/{projectId}/issues/{issueIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var issueTitle = GetNestedString(root, "title") ?? "-";
return $"Issue #{issueIid} ({issueTitle}) успешно закрыт в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("close_issue", ex, $"/projects/{projectId}/issues/{issueIid}");
}
}
/// <summary>
/// Открыть Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Открыть Issue")]
public async Task<string> OpenIssue(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("OpenIssue", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}", RestSharp.Method.Put);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
request.AddJsonBody(new { state_event = "reopen" });
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("open_issue", response, $"/projects/{projectId}/issues/{issueIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var issueTitle = GetNestedString(root, "title") ?? "-";
return $"Issue #{issueIid} ({issueTitle}) успешно открыт в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("open_issue", ex, $"/projects/{projectId}/issues/{issueIid}");
}
}
/// <summary>
/// Получить замечания к Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить замечания к Issue")]
public async Task<string> ListIssueNotes(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssueNotes", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}/notes", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "30");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_issue_notes", response, $"/projects/{projectId}/issues/{issueIid}/notes");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("notes", out var notesElement) || notesElement.GetArrayLength() == 0)
{
return $"Замечаний к Issue #{issueIid} не найдено.";
}
var lines = new List<string>();
foreach (var note in notesElement.EnumerateArray())
{
var author = GetNestedString(note, "author", "name") ?? "-";
var createdAt = GetNestedString(note, "created_at") ?? "-";
var subject = GetNestedString(note, "subject") ?? "-";
var body = GetNestedString(note, "body") ?? "-";
lines.Add($"Author: {author}, Time: {createdAt}\n Subject: {subject}\n Body: {body}");
}
return $"Замечания к Issue #{issueIid} ({notesElement.GetArrayLength()} шт.):\n{string.Join(Environment.NewLine, lines)}";
}
catch (Exception ex)
{
return FormatException("list_issue_notes", ex);
}
}
/// <summary>
/// Добавить замечание к Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="body">Текст замечания</param>
/// <param name="subject">Заголовок замечания (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Добавить замечание к Issue")]
public async Task<string> CreateIssueNote(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
[Description("Текст замечания")] string body,
[Description("Заголовок замечания")] string? subject = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateIssueNote", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(body))
{
return "Текст замечания GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}/notes", RestSharp.Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
request.AddJsonBody(new
{
body,
subject = subject ?? string.Empty
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_issue_note", response, $"/projects/{projectId}/issues/{issueIid}/notes");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var noteId = GetNestedString(root, "id") ?? "-";
var noteSubject = GetNestedString(root, "subject") ?? "-";
var noteBody = GetNestedString(root, "body") ?? "-";
return $"Замечание успешно добавлено к Issue #{issueIid}:\nID: #{noteId}\n{noteSubject}\n{noteBody}";
}
catch (Exception ex)
{
return FormatException("create_issue_note", ex);
}
}
/// <summary>
/// Удалить замечание из Issue
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="issueIid">ID Issue</param>
/// <param name="noteId">ID замечания</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Удалить замечание из Issue")]
public async Task<string> DeleteIssueNote(
[Description("ID проекта")] int projectId,
[Description("ID Issue")] int issueIid,
[Description("ID замечания")] int noteId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeleteIssueNote", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (issueIid <= 0)
{
return "ID Issue GitLab некорректно задан.";
}
if (noteId <= 0)
{
return "ID замечания GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/issues/{issueIid}/notes/{noteId}", RestSharp.Method.Delete);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("delete_issue_note", response, $"/projects/{projectId}/issues/{issueIid}/notes/{noteId}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var noteSubject = GetNestedString(root, "subject") ?? "-";
return $"Замечание #{noteId} ({noteSubject}) успешно удалено из Issue #{issueIid}.";
}
catch (Exception ex)
{
return FormatException("delete_issue_note", ex, $"/projects/{projectId}/issues/{issueIid}/notes/{noteId}");
}
}
}

View File

@@ -0,0 +1,607 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
public sealed class GitLabMergeRequestTools(
GitLabClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly string _token = configuration["GitLab:Token"] ?? string.Empty;
private readonly string _baseUrl = configuration["GitLab:Url"] ?? string.Empty;
private const string ModuleName = "GitLab";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
private bool TryGetClient(out RestSharp.IRestClient client, out string error)
{
if (provider.InitializationError is not null)
{
client = null!;
error = $"GitLab клиент не инициализирован. Детали: {provider.InitializationError}";
return false;
}
var clientInstance = provider.GetClient();
if (clientInstance is null)
{
client = null!;
error = "GitLab клиент не создан.";
return false;
}
client = clientInstance.RestClient;
error = string.Empty;
return true;
}
private string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
private static string GetVisibility(string visibility) => visibility switch
{
"public" => "Public",
"internal" => "Internal",
"private" => "Private",
_ => visibility ?? "unknown"
};
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
private static string GetState(string state) => state switch
{
"opened" => "Opened",
"merged" => "Merged",
"closed" => "Closed",
"declined" => "Declined",
_ => state ?? "unknown"
};
/// <summary>
/// Получить список MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить список Merge Requests")]
public async Task<string> ListMergeRequests(
[Description("ID проекта")] int projectId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListMergeRequests", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "30");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_merge_requests", response, $"/projects/{projectId}/merge_requests");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("merge_requests", out var mrElement) || mrElement.GetArrayLength() == 0)
{
return $"Merge Request в проекте #{projectId} не найдены.";
}
var lines = new List<string>();
foreach (var mr in mrElement.EnumerateArray())
{
var iid = GetNestedString(mr, "iid") ?? "-";
var title = GetNestedString(mr, "title") ?? "-";
var state = GetState(GetNestedString(mr, "state") ?? "");
var sourceBranch = GetNestedString(mr, "source", "branch") ?? "-";
var targetBranch = GetNestedString(mr, "target", "branch") ?? "-";
var author = GetNestedString(mr, "author", "name") ?? "-";
var webUrl = GetNestedString(mr, "web_url") ?? "-";
lines.Add($"#{iid} - {state}\n {title}");
lines.Add($" {sourceBranch} -> {targetBranch}\n author: {author}\n URL: {webUrl}");
}
return $"Merge Requests проекта #{projectId} ({mrElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("list_merge_requests", ex);
}
}
/// <summary>
/// Получить конкретный MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="mrIid">ID Merge Request</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить конкретный Merge Request")]
public async Task<string> GetMergeRequest(
[Description("ID проекта")] int projectId,
[Description("ID Merge Request")] int mrIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetMergeRequest", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (mrIid <= 0)
{
return "ID Merge Request GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_merge_request", response, $"/projects/{projectId}/merge_requests/{mrIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var iid = GetNestedString(root, "iid") ?? "-";
var title = GetNestedString(root, "title") ?? "-";
var state = GetState(GetNestedString(root, "state") ?? "");
var sourceBranch = GetNestedString(root, "source", "branch") ?? "-";
var targetBranch = GetNestedString(root, "target", "branch") ?? "-";
var author = GetNestedString(root, "author", "name") ?? "-";
var webUrl = GetNestedString(root, "web_url") ?? "-";
var mergedAt = GetNestedString(root, "merged_at") ?? "-";
var status = GetNestedString(root, "status") ?? "unknown";
return $"Merge Request #{iid} в проекте #{projectId}:\n{title} [{state}]\n {sourceBranch} -> {targetBranch}\n author: {author}\n URL: {webUrl}\n merged_at: {mergedAt}\n status: {status}";
}
catch (Exception ex)
{
return FormatException("get_merge_request", ex, $"/projects/{projectId}/merge_requests/{mrIid}");
}
}
/// <summary>
/// Создать Merge Request
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="title">Заголовок MR</param>
/// <param name="sourceBranch">Имя ветки источника</param>
/// <param name="targetBranch">Имя целевой ветки</param>
/// <param name="description">Описание (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Создать Merge Request")]
public async Task<string> CreateMergeRequest(
[Description("ID проекта")] int projectId,
[Description("Заголовок MR")] string title,
[Description("Имя ветки источника")] string sourceBranch,
[Description("Имя целевой ветки")] string targetBranch,
[Description("Описание MR")] string? description = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateMergeRequest", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(title))
{
return "Заголовок MR GitLab не может быть пустым.";
}
if (string.IsNullOrWhiteSpace(sourceBranch))
{
return "Имя ветки источника GitLab не может быть пустым.";
}
if (string.IsNullOrWhiteSpace(targetBranch))
{
return "Имя целевой ветки GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests", RestSharp.Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var jsonBody = new
{
title = title,
source_branch = sourceBranch,
target_branch = targetBranch,
description = description ?? string.Empty
}.ToJson();
request.AddJsonBody(jsonBody);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_merge_request", response, $"/projects/{projectId}/merge_requests");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var iid = GetNestedString(root, "iid") ?? "-";
var mrTitle = GetNestedString(root, "title") ?? "-";
var state = GetState(GetNestedString(root, "state") ?? "");
var webUrl = GetNestedString(root, "web_url") ?? "-";
return $"Merge Request успешно создан в проекте #{projectId}:\nID: #{iid}\n{mrTitle} [{state}]\nURL: {webUrl}";
}
catch (Exception ex)
{
return FormatException("create_merge_request", ex);
}
}
/// <summary>
/// Закрыть MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="mrIid">ID Merge Request</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Закрыть Merge Request")]
public async Task<string> CloseMergeRequest(
[Description("ID проекта")] int projectId,
[Description("ID Merge Request")] int mrIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CloseMergeRequest", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (mrIid <= 0)
{
return "ID Merge Request GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}", RestSharp.Method.Put);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var jsonBody = new { state = "closed" }.ToJson();
request.AddJsonBody(jsonBody);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("close_merge_request", response, $"/projects/{projectId}/merge_requests/{mrIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var mrTitle = GetNestedString(root, "title") ?? "-";
return $"Merge Request #{mrIid} ({mrTitle}) успешно закрыт в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("close_merge_request", ex, $"/projects/{projectId}/merge_requests/{mrIid}");
}
}
/// <summary>
/// Открыть MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="mrIid">ID Merge Request</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Открыть Merge Request")]
public async Task<string> OpenMergeRequest(
[Description("ID проекта")] int projectId,
[Description("ID Merge Request")] int mrIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("OpenMergeRequest", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (mrIid <= 0)
{
return "ID Merge Request GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}", RestSharp.Method.Put);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var jsonBody = new { state = "opened" }.ToJson();
request.AddJsonBody(jsonBody);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("open_merge_request", response, $"/projects/{projectId}/merge_requests/{mrIid}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var mrTitle = GetNestedString(root, "title") ?? "-";
return $"Merge Request #{mrIid} ({mrTitle}) успешно открыт в проекте #{projectId}.";
}
catch (Exception ex)
{
return FormatException("open_merge_request", ex, $"/projects/{projectId}/merge_requests/{mrIid}");
}
}
/// <summary>
/// Получить замечания к MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="mrIid">ID Merge Request</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить замечания к Merge Request")]
public async Task<string> ListMergeRequestNotes(
[Description("ID проекта")] int projectId,
[Description("ID Merge Request")] int mrIid,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListMergeRequestNotes", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (mrIid <= 0)
{
return "ID Merge Request GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}/notes", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "30");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_merge_request_notes", response, $"/projects/{projectId}/merge_requests/{mrIid}/notes");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("notes", out var notesElement) || notesElement.GetArrayLength() == 0)
{
return $"Замечаний к MR #{mrIid} не найдено.";
}
var lines = new List<string>();
foreach (var note in notesElement.EnumerateArray())
{
var author = GetNestedString(note, "author", "name") ?? "-";
var createdAt = GetNestedString(note, "created_at") ?? "-";
var subject = GetNestedString(note, "subject") ?? "-";
var body = GetNestedString(note, "body") ?? "-";
lines.Add($"Author: {author}, Time: {createdAt}\n Subject: {subject}\n Body: {body}");
}
return $"Замечания к MR #{mrIid} ({notesElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("list_merge_request_notes", ex);
}
}
/// <summary>
/// Добавить замечание к MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="mrIid">ID Merge Request</param>
/// <param name="body">Текст замечания</param>
/// <param name="subject">Заголовок замечания (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Добавить замечание к Merge Request")]
public async Task<string> CreateMergeRequestNote(
[Description("ID проекта")] int projectId,
[Description("ID Merge Request")] int mrIid,
[Description("Текст замечания")] string body,
[Description("Заголовок замечания")] string? subject = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateMergeRequestNote", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (mrIid <= 0)
{
return "ID Merge Request GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(body))
{
return "Текст замечания GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}/notes", RestSharp.Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var jsonBody = new
{
body = body,
subject = subject ?? string.Empty
}.ToJson();
request.AddJsonBody(jsonBody);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_merge_request_note", response, $"/projects/{projectId}/merge_requests/{mrIid}/notes");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var noteId = GetNestedString(root, "id") ?? "-";
var noteSubject = GetNestedString(root, "subject") ?? "-";
var noteBody = GetNestedString(root, "body") ?? "-";
return $"Замечание успешно добавлено к MR #{mrIid}:\nID: #{noteId}\n{noteSubject}\n{noteBody}";
}
catch (Exception ex)
{
return FormatException("create_merge_request_note", ex);
}
}
/// <summary>
/// Удалить замечание из MR
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="mrIid">ID Merge Request</param>
/// <param name="noteId">ID замечания</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Удалить замечание из Merge Request")]
public async Task<string> DeleteMergeRequestNote(
[Description("ID проекта")] int projectId,
[Description("ID Merge Request")] int mrIid,
[Description("ID замечания")] int noteId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeleteMergeRequestNote", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (mrIid <= 0)
{
return "ID Merge Request GitLab некорректно задан.";
}
if (noteId <= 0)
{
return "ID замечания GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/merge_requests/{mrIid}/notes/{noteId}", RestSharp.Method.Delete);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("delete_merge_request_note", response, $"/projects/{projectId}/merge_requests/{mrIid}/notes/{noteId}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var noteSubject = GetNestedString(root, "subject") ?? "-";
return $"Замечание #{noteId} ({noteSubject}) успешно удалено из MR #{mrIid}.";
}
catch (Exception ex)
{
return FormatException("delete_merge_request_note", ex, $"/projects/{projectId}/merge_requests/{mrIid}/notes/{noteId}");
}
}
}

View File

@@ -0,0 +1,175 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
public sealed class GitLabRepositoryTools(
GitLabClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly string _token = configuration["GitLab:Token"] ?? string.Empty;
private readonly string _baseUrl = configuration["GitLab:Url"] ?? string.Empty;
private const string ModuleName = "GitLab";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
private bool TryGetClient(out RestSharp.IRestClient client, out string error)
{
if (provider.InitializationError is not null)
{
client = null!;
error = $"GitLab клиент не инициализирован. Детали: {provider.InitializationError}";
return false;
}
var clientInstance = provider.GetClient();
if (clientInstance is null)
{
client = null!;
error = "GitLab клиент не создан.";
return false;
}
client = clientInstance.RestClient;
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить список репозиториев текущего пользователя")]
public async Task<string> ListProjects(CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListProjects", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest("/user/projects", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "100");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_projects", response, "/user/projects");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("projects", out var projectsElement) || projectsElement.GetArrayLength() == 0)
{
return "Репозитории GitLab не найдены.";
}
var lines = new List<string>();
foreach (var project in projectsElement.EnumerateArray())
{
var name = GetNestedString(project, "name") ?? "unknown";
var path = GetNestedString(project, "path") ?? "-";
var visibility = GetVisibility(GetNestedString(project, "visibility") ?? "");
lines.Add($"{name} [{visibility}] - {path}");
}
return $"Репозитории GitLab ({projectsElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("list_projects", ex);
}
}
[McpServerTool, Description("Получить конкретный репозиторий по ID")]
public async Task<string> GetProject(
[Description("ID репозитория")] int projectId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetProject", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID репозитория GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_project", response, $"/projects/{projectId}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var name = GetNestedString(root, "name") ?? "-";
var path = GetNestedString(root, "path") ?? "-";
var visibility = GetVisibility(GetNestedString(root, "visibility") ?? "");
var httpUrl = GetNestedString(root, "http_url_to_repo") ?? "-";
var webUrl = GetNestedString(root, "web_url") ?? "-";
var sshUrl = GetNestedString(root, "ssh_url_to_repo") ?? "-";
return $"Репозиторий #{projectId}:\n{name} [{visibility}] - {path}\nURL: {httpUrl}\nWeb: {webUrl}\nSSH: {sshUrl}";
}
catch (Exception ex)
{
return FormatException("get_project", ex, $"/projects/{projectId}");
}
}
private static string GetVisibility(string visibility) => visibility switch
{
"public" => "Public",
"internal" => "Internal",
"private" => "Private",
_ => visibility ?? "unknown"
};
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
private string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
}

View File

@@ -0,0 +1,51 @@
using LazyBear.MCP.Services.ToolRegistry;
namespace LazyBear.MCP.Services.GitLab;
public sealed class GitLabToolModule : IToolModule
{
public string ModuleName => "GitLab";
public string Description => "GitLab: репозитории, теги, MR, issues, ветки";
public IReadOnlyList<string> ToolNames =>
[
// Repositories
"ListProjects",
"GetProject",
// Versions (tags)
"CreateVersion",
"ListVersions",
"DeleteVersion",
// Merge Requests
"ListMergeRequests",
"GetMergeRequest",
"CreateMergeRequest",
"CloseMergeRequest",
"OpenMergeRequest",
"ListMergeRequestNotes",
"CreateMergeRequestNote",
"DeleteMergeRequestNote",
// Issues
"ListIssues",
"ListIssuesSimple",
"GetIssue",
"CreateIssue",
"UpdateIssue",
"CloseIssue",
"OpenIssue",
"ListIssueNotes",
"CreateIssueNote",
"DeleteIssueNote",
// Branches
"ListBranches",
"GetBranch",
"CreateBranch",
"DeleteBranch",
"ProtectBranch",
"UnprotectBranch"
];
}

View File

@@ -0,0 +1,169 @@
using System.Collections.Generic;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
/// <summary>
/// Базовый класс для всех инструментов GitLab
/// </summary>
public sealed class GitLabToolsBase
{
protected readonly GitLabApiClient _client;
protected readonly string _baseUrl;
protected readonly int _perPageDefault;
private readonly string _token;
private readonly string _baseUrlConfig;
private readonly ToolRegistryService _registry;
/// <summary>
/// Ошибка инициализации клиента (если возникла)
/// </summary>
protected string? ClientInitializationError { get; }
/// <summary>
/// Конструктор
/// </summary>
/// <param name="baseUrlConfig">Конфигурация URL</param>
/// <param name="token">API токен</param>
/// <param name="registry">Регистратор инструментов</param>
public GitLabToolsBase(
string baseUrlConfig,
string token,
ToolRegistryService registry)
{
_token = token;
_baseUrlConfig = baseUrlConfig;
_registry = registry;
// Инициализация клиента
_baseUrl = baseUrlConfig;
_client = new GitLabApiClient(baseUrlConfig);
}
/// <summary>
/// Проверка, активирован ли инструмент в TUI
/// </summary>
protected bool TryCheckEnabled(string toolName, out string error)
{
if (!_registry.IsToolEnabled("GitLab", toolName))
{
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
/// <summary>
/// Получение клиента RestSharp
/// </summary>
protected bool TryGetClient(out GitLabApiClient client, out string error)
{
client = _client;
error = ClientInitializationError is null
? string.Empty
: $"GitLab клиент не инициализирован. Проверьте GitLab:Url. Детали: {ClientInitializationError}";
return ClientInitializationError is null;
}
/// <summary>
/// Создание запроса к GitLab API
/// </summary>
protected RestRequest CreateRequest(string resource, RestSharp.Method method = RestSharp.Method.Get)
{
var request = _client.GetRequest(resource);
return request;
}
/// <summary>
/// Форматирование ошибки ответа от GitLab API
/// </summary>
protected string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource)
? string.Empty
: $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content)
? "-"
: response.Content;
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
/// <summary>
/// Форматирование исключения
/// </summary>
protected string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource)
? string.Empty
: $", resource='{resource}'";
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
/// <summary>
/// Получение вложенного строки из Json
/// </summary>
protected static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String
? current.GetString()
: current.ToString();
}
/// <summary>
/// Экстракция текста из комментариев GitLab
/// </summary>
protected static string ExtractCommentText(JsonElement body)
{
var chunks = new List<string>();
CollectText(body, chunks);
return chunks.Count == 0 ? "-" : string.Join(" ", chunks);
}
/// <summary>
/// Рекурсивный сбор текста из JSON
/// </summary>
protected static void CollectText(JsonElement element, List<string> chunks)
{
if (element.ValueKind == JsonValueKind.Object)
{
// Ищем текстовый узел в структуре комментария GitLab
if (element.TryGetProperty("body", out var bodyElement) &&
bodyElement.TryGetProperty("text", out var textElement))
{
if (textElement.ValueKind == JsonValueKind.String)
{
chunks.Add(textElement.GetString() ?? string.Empty);
}
}
foreach (var property in element.EnumerateObject())
{
CollectText(property.Value, chunks);
}
return;
}
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
CollectText(item, chunks);
}
}
}
}

View File

@@ -0,0 +1,269 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.GitLab;
public sealed class GitLabVersionTools(
GitLabClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly string _token = configuration["GitLab:Token"] ?? string.Empty;
private readonly string _baseUrl = configuration["GitLab:Url"] ?? string.Empty;
private const string ModuleName = "GitLab";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля GitLab отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
private bool TryGetClient(out RestSharp.IRestClient client, out string error)
{
if (provider.InitializationError is not null)
{
client = null!;
error = $"GitLab клиент не инициализирован. Детали: {provider.InitializationError}";
return false;
}
var clientInstance = provider.GetClient();
if (clientInstance is null)
{
client = null!;
error = "GitLab клиент не создан.";
return false;
}
client = clientInstance.RestClient;
error = string.Empty;
return true;
}
private string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка GitLab в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
private static string GetVisibility(string visibility) => visibility switch
{
"public" => "Public",
"internal" => "Internal",
"private" => "Private",
_ => visibility ?? "unknown"
};
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
/// <summary>
/// Получить список тегов
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Получить список тегов проекта")]
public async Task<string> ListVersions(
[Description("ID проекта")] int projectId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListVersions", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/repository/tags", RestSharp.Method.Get);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddQueryParameter("per_page", "30");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_versions", response, $"/projects/{projectId}/repository/tags");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("tags", out var tagsElement) || tagsElement.GetArrayLength() == 0)
{
return $"Тегов в проекте #{projectId} не найдено.";
}
var lines = new List<string>();
foreach (var tag in tagsElement.EnumerateArray())
{
var name = GetNestedString(tag, "name") ?? "-";
var commitSha = GetNestedString(tag, "commit", "sha") ?? "-";
var commitMessage = GetNestedString(tag, "commit", "message") ?? "-";
var tagType = GetNestedString(tag, "tag_type") ?? "unknown";
lines.Add($"'{name}' (type={tagType}, sha={commitSha})\n message: {commitMessage}");
}
return $"Тег версии проекта #{projectId} ({tagsElement.GetArrayLength()} шт.):\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("list_versions", ex);
}
}
/// <summary>
/// Создать тег
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="name">Имя тега</param>
/// <param name="description">Описание (опционально)</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Создать тег версии")]
public async Task<string> CreateVersion(
[Description("ID проекта")] int projectId,
[Description("Имя тега")] string name,
[Description("Описание тега")] string? description = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateVersion", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(name))
{
return "Имя тега GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/repository/tags", RestSharp.Method.Post);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
request.AddHeader("Content-Type", "application/json");
var jsonBody = new
{
name = name,
description = description ?? string.Empty
}.ToJson();
request.AddJsonBody(jsonBody);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_version", response, $"/projects/{projectId}/repository/tags");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var tagName = GetNestedString(root, "name") ?? "-";
var sha = GetNestedString(root, "commit", "sha") ?? "-";
var refType = GetNestedString(root, "ref_type") ?? "-";
return $"Тег версии создан в проекте #{projectId}:\n'{tagName}' (ref_type={refType}, sha={sha})";
}
catch (Exception ex)
{
return FormatException("create_version", ex);
}
}
/// <summary>
/// Удалить тег
/// </summary>
/// <param name="projectId">ID проекта</param>
/// <param name="tagName">Имя тега</param>
/// <param name="cancellationToken">Token отмены</param>
[McpServerTool, Description("Удалить тег версии")]
public async Task<string> DeleteVersion(
[Description("ID проекта")] int projectId,
[Description("Имя тега")] string tagName,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeleteVersion", out var enabledError)) return enabledError;
if (projectId <= 0)
{
return "ID проекта GitLab некорректно задан.";
}
if (string.IsNullOrWhiteSpace(tagName))
{
return "Имя тега GitLab не может быть пустым.";
}
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = new RestRequest($"/projects/{projectId}/repository/tags/{tagName}", RestSharp.Method.Delete);
request.AddHeader("Accept", "application/json");
request.AddHeader("PRIVATE-TOKEN", _token);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("delete_version", response, $"/projects/{projectId}/repository/tags/{tagName}");
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var deletedTag = GetNestedString(root, "name") ?? tagName;
return $"Тег '{deletedTag}' успешно удалён из проекта #{projectId}.";
}
catch (Exception ex)
{
return FormatException("delete_version", ex);
}
}
}
internal static class JsonExtensions
{
public static string ToJson(this object obj) =>
System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = false
});
}

View File

@@ -0,0 +1,66 @@
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 Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LazyBear.MCP.Services.Mcp;
/// <summary>
/// Поднимает HTTP MCP endpoint в фоне, не вмешиваясь в основной TUI event loop.
/// Использует общие singleton-экземпляры из root host.
/// </summary>
public sealed class McpWebHostedService(
IServiceProvider rootServices,
IConfiguration configuration,
ILogger<McpWebHostedService> logger) : IHostedService
{
private WebApplication? _webApp;
public async Task StartAsync(CancellationToken cancellationToken)
{
var builder = WebApplication.CreateBuilder();
var urls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000";
builder.WebHost.UseUrls(urls);
// Используем тот же IConfiguration и те же singleton-сервисы, что и в TUI host.
builder.Services.AddSingleton(configuration);
builder.Services.AddSingleton(rootServices.GetRequiredService<InMemoryLogSink>());
builder.Services.AddSingleton(rootServices.GetRequiredService<ToolRegistryService>());
builder.Services.AddSingleton(rootServices.GetRequiredService<K8sClientProvider>());
builder.Services.AddSingleton(rootServices.GetRequiredService<JiraClientProvider>());
builder.Services.AddSingleton(rootServices.GetRequiredService<ConfluenceClientProvider>());
foreach (var module in rootServices.GetServices<IToolModule>())
{
builder.Services.AddSingleton(module);
builder.Services.AddSingleton(typeof(IToolModule), module);
}
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
_webApp = builder.Build();
_webApp.MapMcp();
await _webApp.StartAsync(cancellationToken);
logger.LogInformation("HTTP MCP endpoint запущен на {Urls}", urls);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_webApp is null)
{
return;
}
await _webApp.StopAsync(cancellationToken);
await _webApp.DisposeAsync();
_webApp = null;
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Qdrant;
public sealed class QdrantClientProvider
{
public RestClient? Client { get; }
public string? InitializationError { get; }
public QdrantClientProvider(IConfiguration configuration)
{
try
{
var url = configuration["Qdrant:Url"];
if (string.IsNullOrWhiteSpace(url))
{
throw new InvalidOperationException("Qdrant:Url не настроен в конфигурации.");
}
Client = new RestClient(url);
}
catch (Exception ex)
{
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,333 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.Qdrant;
[McpServerToolType]
public sealed class QdrantKnowledgeTools(
QdrantClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly RestClient? _client = provider.Client;
private readonly string? _clientInitializationError = provider.InitializationError;
private readonly string _apiKey = configuration["Qdrant:ApiKey"] ?? string.Empty;
private readonly string _defaultCollection = configuration["Qdrant:DefaultCollection"] ?? "knowledge";
private const string ModuleName = "Qdrant";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Qdrant отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить список коллекций Qdrant")]
public async Task<string> ListCollections(CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListCollections", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = CreateRequest("/collections");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_collections", response);
}
using var document = JsonDocument.Parse(response.Content);
if (!document.RootElement.TryGetProperty("result", out var resultElement) ||
!resultElement.TryGetProperty("collections", out var collectionsElement) ||
collectionsElement.GetArrayLength() == 0)
{
return "Коллекции Qdrant не найдены.";
}
var names = collectionsElement
.EnumerateArray()
.Select(item => GetNestedString(item, "name") ?? "unknown")
.ToArray();
return $"Коллекции Qdrant ({names.Length} шт.): {string.Join(", ", names)}";
}
catch (Exception ex)
{
return FormatException("list_collections", ex);
}
}
[McpServerTool, Description("Создать коллекцию Qdrant для базы знаний")]
public async Task<string> CreateCollection(
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
[Description("Размер вектора") ] int vectorSize = 1536,
[Description("Метрика расстояния: Cosine, Euclid, Dot или Manhattan")] string distance = "Cosine",
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateCollection", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
var resolvedCollection = ResolveCollection(collection);
if (vectorSize <= 0)
return "vectorSize должен быть больше 0.";
if (distance is not ("Cosine" or "Euclid" or "Dot" or "Manhattan"))
return $"distance должен быть одним из: Cosine, Euclid, Dot, Manhattan. Получено: '{distance}'.";
try
{
var request = CreateRequest($"/collections/{resolvedCollection}", Method.Put);
request.AddJsonBody(new
{
vectors = new
{
size = vectorSize,
distance
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("create_collection", response, resolvedCollection);
}
return $"Коллекция Qdrant '{resolvedCollection}' создана (vectorSize={vectorSize}, distance={distance}).";
}
catch (Exception ex)
{
return FormatException("create_collection", ex, resolvedCollection);
}
}
[McpServerTool, Description("Добавить или обновить документ в базе знаний Qdrant")]
public async Task<string> UpsertKnowledgeDocument(
[Description("ID документа/точки") ] string id,
[Description("Вектор embedding") ] float[] vector,
[Description("Текст/контент документа") ] string content,
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
[Description("Дополнительные метаданные JSON-объектом") ] Dictionary<string, object?>? metadata = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpsertKnowledgeDocument", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
if (string.IsNullOrWhiteSpace(id)) return "id документа не задан.";
if (!Guid.TryParse(id, out _) && !ulong.TryParse(id, out _))
return $"id должен быть UUID (например, 550e8400-e29b-41d4-a716-446655440000) или uint64. Получено: '{id}'.";
if (vector is null || vector.Length == 0) return "vector не задан.";
var resolvedCollection = ResolveCollection(collection);
try
{
var payload = new Dictionary<string, object?>
{
["content"] = content,
["updatedAt"] = DateTimeOffset.UtcNow
};
if (metadata is not null)
{
foreach (var (key, value) in metadata)
{
payload[key] = value;
}
}
var request = CreateRequest($"/collections/{resolvedCollection}/points", Method.Put);
request.AddJsonBody(new
{
points = new object[]
{
new
{
id,
vector,
payload
}
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("upsert_knowledge_document", response, resolvedCollection);
}
return $"Документ '{id}' сохранён в коллекции '{resolvedCollection}'.";
}
catch (Exception ex)
{
return FormatException("upsert_knowledge_document", ex, resolvedCollection);
}
}
[McpServerTool, Description("Векторный поиск по базе знаний Qdrant")]
public async Task<string> SearchKnowledge(
[Description("Вектор запроса") ] float[] queryVector,
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
[Description("Количество результатов") ] int limit = 5,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("SearchKnowledge", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
if (queryVector is null || queryVector.Length == 0) return "queryVector не задан.";
var resolvedCollection = ResolveCollection(collection);
var resolvedLimit = Math.Max(1, limit);
try
{
var request = CreateRequest($"/collections/{resolvedCollection}/points/query", Method.Post);
request.AddJsonBody(new
{
query = queryVector,
limit = resolvedLimit,
with_payload = true
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("search_knowledge", response, resolvedCollection);
}
using var document = JsonDocument.Parse(response.Content);
if (!document.RootElement.TryGetProperty("result", out var resultsElement) ||
resultsElement.GetArrayLength() == 0)
{
return "Поиск по базе знаний не дал результатов.";
}
var lines = new List<string>();
foreach (var item in resultsElement.EnumerateArray())
{
var pointId = item.TryGetProperty("id", out var idElement) ? idElement.ToString() : "-";
var score = item.TryGetProperty("score", out var scoreElement)
? scoreElement.GetDouble().ToString("0.####")
: "-";
var content = (GetNestedString(item, "payload", "content") ?? "(без контента)")
.ReplaceLineEndings(" ");
lines.Add($"id={pointId}; score={score}; content={content}");
}
return $"Результаты поиска Qdrant ({lines.Count}):\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("search_knowledge", ex, resolvedCollection);
}
}
[McpServerTool, Description("Удалить документ из базы знаний Qdrant по ID")]
public async Task<string> DeleteKnowledgeDocument(
[Description("ID документа/точки") ] string id,
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeleteKnowledgeDocument", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
if (string.IsNullOrWhiteSpace(id)) return "id документа не задан.";
if (!Guid.TryParse(id, out _) && !ulong.TryParse(id, out _))
return $"id должен быть UUID (например, 550e8400-e29b-41d4-a716-446655440000) или uint64. Получено: '{id}'.";
var resolvedCollection = ResolveCollection(collection);
try
{
var request = CreateRequest($"/collections/{resolvedCollection}/points/delete", Method.Post);
request.AddJsonBody(new
{
points = new[] { id }
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("delete_knowledge_document", response, resolvedCollection);
}
return $"Документ '{id}' удалён из коллекции '{resolvedCollection}'.";
}
catch (Exception ex)
{
return FormatException("delete_knowledge_document", ex, resolvedCollection);
}
}
private string ResolveCollection(string? collection)
{
return string.IsNullOrWhiteSpace(collection) ? _defaultCollection : collection;
}
private RestRequest CreateRequest(string resource, Method method = Method.Get)
{
var request = new RestRequest(resource, method);
request.AddHeader("Accept", "application/json");
if (!string.IsNullOrWhiteSpace(_apiKey))
{
request.AddHeader("api-key", _apiKey);
}
return request;
}
private bool TryGetClient(out RestClient client, out string error)
{
if (_client is null)
{
client = null!;
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
? string.Empty
: $" Детали: {_clientInitializationError}";
error = "Qdrant клиент не инициализирован. Проверьте Qdrant:Url." + details;
return false;
}
client = _client;
error = string.Empty;
return true;
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
private static string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка Qdrant в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private static string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка Qdrant в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
}

View File

@@ -0,0 +1,18 @@
using LazyBear.MCP.Services.ToolRegistry;
namespace LazyBear.MCP.Services.Qdrant;
public sealed class QdrantToolModule : IToolModule
{
public string ModuleName => "Qdrant";
public string Description => "Qdrant: база знаний (коллекции, документы, векторный поиск)";
public IReadOnlyList<string> ToolNames =>
[
"ListCollections",
"CreateCollection",
"UpsertKnowledgeDocument",
"SearchKnowledge",
"DeleteKnowledgeDocument"
];
}

View File

@@ -49,9 +49,12 @@ public sealed class ToolRegistryService
public bool IsModuleEnabled(string moduleName) =>
_moduleEnabled.GetValueOrDefault(moduleName, true);
public bool IsToolConfiguredEnabled(string moduleName, string toolName) =>
_toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true);
public bool IsToolEnabled(string moduleName, string toolName) =>
IsModuleEnabled(moduleName) &&
_toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true);
IsToolConfiguredEnabled(moduleName, toolName);
// ── Переключение ─────────────────────────────────────────────────────────
@@ -71,7 +74,7 @@ public sealed class ToolRegistryService
SetModuleEnabled(moduleName, !IsModuleEnabled(moduleName));
public void ToggleTool(string moduleName, string toolName) =>
SetToolEnabled(moduleName, toolName, !IsToolEnabled(moduleName, toolName));
SetToolEnabled(moduleName, toolName, !IsToolConfiguredEnabled(moduleName, toolName));
// ── Счётчики для Overview ─────────────────────────────────────────────────
@@ -90,6 +93,21 @@ public sealed class ToolRegistryService
}
}
public (int Enabled, int Total) GetConfiguredToolCounts(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 enabled = module.ToolNames.Count(t => IsToolConfiguredEnabled(moduleName, t));
return (enabled, total);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string MakeKey(string module, string tool) => $"{module}::{tool}";

View File

@@ -2,80 +2,737 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
@inject InMemoryLogSink LogSink
@inject GlobalKeyboardService KeyboardService
@inject LocalizationService Localization
@inject IHostApplicationLifetime AppLifetime
@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>
<Rows Expand="true">
<Columns Expand="true">
<Markup Content="@GetHeaderProjectSegment()"
Foreground="@UiPalette.Text"
Background="@UiPalette.HeaderBrandBackground"
Decoration="@Spectre.Console.Decoration.Bold" />
@foreach (var segment in BuildHeaderSegments())
{
<Markup Content="@segment.Content"
Foreground="@segment.Foreground"
Background="@segment.Background"
Decoration="@segment.Decoration" />
}
</Columns>
@* Контент таба *@
<Panel BorderColor="@UiPalette.Frame"
Expand="true"
Height="@GetPanelHeight()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows Expand="true">
@if (_activeTab == Tab.Overview)
{
<OverviewTab />
<OverviewTab Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()"
Loc="@Localization.Current" />
}
else if (_activeTab == Tab.Logs)
{
<LogsTab />
<LogsTab Entries="@GetFilteredLogEntries()"
SelectedIndex="@_logSelection"
SelectedIndexChanged="@OnLogSelectionChanged"
SelectedFilter="@_logFilters[_logFilterIndex]"
ViewportRows="@GetLogsViewportRows()"
IsStickyToBottom="@_logsStickToBottom"
Loc="@Localization.Current" />
}
else
{
<SettingsTab />
<SettingsTab Entries="@GetSettingsEntries()"
SelectedIndex="@_settingsSelection"
SelectedIndexChanged="@OnSettingsSelectionChanged"
ViewportRows="@GetSettingsViewportRows()"
Loc="@Localization.Current" />
}
</Rows>
</Panel>
<Markup Content="@BuildStatusBar(UiMetrics.ConsoleWidth)"
Foreground="@UiPalette.Text"
Background="@UiPalette.SurfaceMuted" />
<ModalWindow IsOpened="@_isHelpOpen">
<Panel Title="@Localization.Current.HelpModalTitle"
TitleColor="@UiPalette.Accent"
BorderColor="@UiPalette.AccentSoft"
Width="@GetHelpModalWidth()"
Padding="@(new Spectre.Console.Padding(1, 0, 1, 0))">
<Rows>
<Markup Content="@Localization.Current.HintBar"
Foreground="@UiPalette.Text" />
<Markup Content=" " />
<Markup Content="@GetHelpLine(Tab.Overview, Localization.Current.OverviewHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetHelpLine(Tab.Logs, Localization.Current.LogsHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetHelpLine(Tab.Settings, Localization.Current.SettingsHint)"
Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Markup Content="@Localization.Current.HelpCloseHint"
Foreground="@UiPalette.AccentSoft"
Decoration="@Spectre.Console.Decoration.Bold" />
</Rows>
</Panel>
</ModalWindow>
</Rows>
@code {
private enum Tab { Overview, Logs, Settings }
private const char Nbsp = '\u00A0';
private enum Tab
{
Overview,
Logs,
Settings
}
private readonly record struct HeaderSegment(
string Content,
Spectre.Console.Color Foreground,
Spectre.Console.Color Background,
Spectre.Console.Decoration Decoration);
private static readonly Tab[] _tabs = [Tab.Overview, Tab.Logs, Tab.Settings];
private static readonly string[] _logFilters = ["All", "Info", "Warn", "Error"];
private readonly HashSet<string> _expandedModules = new(StringComparer.Ordinal);
private Tab _activeTab = Tab.Overview;
private int _overviewSelection;
private int _logFilterIndex;
private int _logSelection;
private int _settingsSelection;
private bool _logsStickToBottom = true;
private bool _isHelpOpen;
private static readonly string _mcpEndpoint =
Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://localhost:5000";
private static int GetPanelHeight() => Math.Max(UiMetrics.ConsoleHeight - 4, 10);
private static int GetOverviewViewportRows() => Math.Max(GetPanelHeight() - 6, 3);
private static int GetLogsViewportRows() => Math.Max(GetPanelHeight() - 10, 5);
private static int GetSettingsViewportRows() => Math.Max(GetPanelHeight() - 7, 5);
private static int GetHelpModalWidth() => Math.Max(Math.Min(UiMetrics.ConsoleWidth - 6, 84), 36);
protected override void OnInitialized()
{
Registry.StateChanged += OnStateChanged;
Registry.StateChanged += OnRegistryChanged;
LogSink.OnLog += OnNewLog;
KeyboardService.OnKeyPressed += OnConsoleKeyPressed;
Localization.OnChanged += OnLocaleChanged;
}
private void SetTab(Tab tab)
// Конвертация ConsoleKeyInfo → KeyboardEventArgs для переиспользования существующей логики
private static KeyboardEventArgs ConvertKey(ConsoleKeyInfo key)
{
_activeTab = tab;
StateHasChanged();
}
private void OnStateChanged()
{
InvokeAsync(StateHasChanged);
}
private void OnNewLog(LogEntry _)
{
if (_activeTab == Tab.Logs)
var name = key.Key switch
{
InvokeAsync(StateHasChanged);
ConsoleKey.UpArrow => "ArrowUp",
ConsoleKey.DownArrow => "ArrowDown",
ConsoleKey.LeftArrow => "ArrowLeft",
ConsoleKey.RightArrow => "ArrowRight",
ConsoleKey.Enter => "Enter",
ConsoleKey.Spacebar => " ",
ConsoleKey.Home => "Home",
ConsoleKey.End => "End",
ConsoleKey.PageUp => "PageUp",
ConsoleKey.PageDown => "PageDown",
ConsoleKey.Tab => "Tab",
ConsoleKey.Escape => "Escape",
_ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString()
};
return new KeyboardEventArgs
{
Key = name,
ShiftKey = (key.Modifiers & ConsoleModifiers.Shift) != 0
};
}
private void OnConsoleKeyPressed(ConsoleKeyInfo key)
{
var args = ConvertKey(key);
if (string.IsNullOrEmpty(args.Key))
{
return;
}
InvokeAsync(() =>
{
HandleKeyDown(args);
StateHasChanged();
});
}
private void OnLocaleChanged() =>
InvokeAsync(StateHasChanged);
private void HandleKeyDown(KeyboardEventArgs args)
{
if (args.Key is "h" or "H")
{
_isHelpOpen = !_isHelpOpen;
return;
}
if (_isHelpOpen)
{
if (args.Key == "Escape")
{
_isHelpOpen = false;
}
return;
}
if (string.Equals(args.Key, "Tab", StringComparison.Ordinal))
{
ChangeTab(args.ShiftKey ? -1 : 1);
return;
}
if (args.Key is "l" or "L")
{
Localization.SwitchNext();
return; // StateHasChanged вызовет OnLocaleChanged
}
if (args.Key is "q" or "Q")
{
AppLifetime.StopApplication();
return;
}
switch (_activeTab)
{
case Tab.Overview:
HandleOverviewKey(args);
break;
case Tab.Logs:
HandleLogsKey(args);
break;
case Tab.Settings:
HandleSettingsKey(args);
break;
}
}
private Task OnOverviewSelectionChanged(int value)
{
var rows = GetOverviewRows();
_overviewSelection = rows.Count == 0 ? 0 : Math.Clamp(value, 0, rows.Count - 1);
return Task.CompletedTask;
}
private Task OnLogSelectionChanged(int value)
{
var entries = GetFilteredLogEntries();
_logSelection = entries.Count == 0 ? 0 : Math.Clamp(value, 0, entries.Count - 1);
_logsStickToBottom = entries.Count == 0 || _logSelection >= entries.Count - 1;
return Task.CompletedTask;
}
private Task OnSettingsSelectionChanged(int value)
{
var entries = GetSettingsEntries();
_settingsSelection = entries.Count == 0 ? 0 : Math.Clamp(value, 0, entries.Count - 1);
return Task.CompletedTask;
}
private void ChangeTab(int step)
{
var currentIndex = Array.IndexOf(_tabs, _activeTab);
if (currentIndex < 0)
{
currentIndex = 0;
}
var nextIndex = (currentIndex + step + _tabs.Length) % _tabs.Length;
_activeTab = _tabs[nextIndex];
ClampSelections();
}
private void HandleOverviewKey(KeyboardEventArgs args)
{
var rows = GetOverviewRows();
if (rows.Count == 0)
{
_overviewSelection = 0;
return;
}
_overviewSelection = Math.Clamp(_overviewSelection, 0, rows.Count - 1);
switch (args.Key)
{
case "ArrowUp":
_overviewSelection = Math.Max(0, _overviewSelection - 1);
break;
case "ArrowDown":
_overviewSelection = Math.Min(rows.Count - 1, _overviewSelection + 1);
break;
case "Home":
_overviewSelection = 0;
break;
case "End":
_overviewSelection = rows.Count - 1;
break;
case "Enter":
_activeTab = Tab.Settings;
SelectSettingsModule(rows[_overviewSelection].ModuleName);
break;
}
}
private void HandleLogsKey(KeyboardEventArgs args)
{
switch (args.Key)
{
case "ArrowLeft":
_logFilterIndex = (_logFilterIndex - 1 + _logFilters.Length) % _logFilters.Length;
ResetLogsSelectionToBottom();
return;
case "ArrowRight":
_logFilterIndex = (_logFilterIndex + 1) % _logFilters.Length;
ResetLogsSelectionToBottom();
return;
}
var entries = GetFilteredLogEntries();
if (entries.Count == 0)
{
_logSelection = 0;
_logsStickToBottom = true;
return;
}
_logSelection = Math.Clamp(_logSelection, 0, entries.Count - 1);
var page = Math.Max(GetLogsViewportRows() - 1, 1);
switch (args.Key)
{
case "ArrowUp":
_logSelection = Math.Max(0, _logSelection - 1);
break;
case "ArrowDown":
_logSelection = Math.Min(entries.Count - 1, _logSelection + 1);
break;
case "PageUp":
_logSelection = Math.Max(0, _logSelection - page);
break;
case "PageDown":
case " ":
case "Spacebar":
_logSelection = Math.Min(entries.Count - 1, _logSelection + page);
break;
case "Home":
_logSelection = 0;
break;
case "End":
_logSelection = entries.Count - 1;
break;
}
_logsStickToBottom = _logSelection >= entries.Count - 1;
}
private void HandleSettingsKey(KeyboardEventArgs args)
{
var entries = GetSettingsEntries();
if (entries.Count == 0)
{
_settingsSelection = 0;
return;
}
_settingsSelection = Math.Clamp(_settingsSelection, 0, entries.Count - 1);
var selected = entries[_settingsSelection];
var page = Math.Max(GetSettingsViewportRows() - 1, 1);
switch (args.Key)
{
case "ArrowUp":
_settingsSelection = Math.Max(0, _settingsSelection - 1);
return;
case "ArrowDown":
_settingsSelection = Math.Min(entries.Count - 1, _settingsSelection + 1);
return;
case "PageUp":
_settingsSelection = Math.Max(0, _settingsSelection - page);
return;
case "PageDown":
_settingsSelection = Math.Min(entries.Count - 1, _settingsSelection + page);
return;
case "Home":
_settingsSelection = 0;
return;
case "End":
_settingsSelection = entries.Count - 1;
return;
case "ArrowRight":
ExpandModule(selected);
return;
case "ArrowLeft":
CollapseModuleOrFocusParent(selected);
return;
case "Enter":
ToggleExpansion(selected);
return;
case " ":
case "Spacebar":
ToggleSetting(selected);
return;
}
}
private void ExpandModule(SettingsEntry entry)
{
if (entry.Kind != SettingsEntryKind.Module || entry.IsExpanded)
{
return;
}
_expandedModules.Add(entry.ModuleName);
ClampSelections();
}
private void CollapseModuleOrFocusParent(SettingsEntry entry)
{
if (entry.Kind == SettingsEntryKind.Module)
{
if (_expandedModules.Remove(entry.ModuleName))
{
ClampSelections();
}
return;
}
_expandedModules.Remove(entry.ModuleName);
SelectSettingsModule(entry.ModuleName);
}
private void ToggleExpansion(SettingsEntry entry)
{
if (entry.Kind != SettingsEntryKind.Module)
{
ToggleSetting(entry);
return;
}
if (_expandedModules.Contains(entry.ModuleName))
{
_expandedModules.Remove(entry.ModuleName);
}
else
{
_expandedModules.Add(entry.ModuleName);
}
SelectSettingsModule(entry.ModuleName);
}
private void ToggleSetting(SettingsEntry entry)
{
if (entry.Kind == SettingsEntryKind.Module)
{
Registry.ToggleModule(entry.ModuleName);
return;
}
if (!string.IsNullOrWhiteSpace(entry.ToolName))
{
Registry.ToggleTool(entry.ModuleName, entry.ToolName);
}
}
private void SelectSettingsModule(string moduleName)
{
var entries = GetSettingsEntries();
var index = entries.FindIndex(entry =>
entry.Kind == SettingsEntryKind.Module &&
string.Equals(entry.ModuleName, moduleName, StringComparison.Ordinal));
_settingsSelection = index >= 0 ? index : 0;
}
private void ResetLogsSelectionToBottom()
{
var entries = GetFilteredLogEntries();
_logSelection = Math.Max(entries.Count - 1, 0);
_logsStickToBottom = true;
}
private List<OverviewRow> GetOverviewRows() =>
Registry.GetModules()
.Select(module =>
{
var (configuredTools, totalTools) = Registry.GetConfiguredToolCounts(module.ModuleName);
return new OverviewRow(
module.ModuleName,
module.Description,
Registry.IsModuleEnabled(module.ModuleName),
configuredTools,
totalTools);
})
.ToList();
private List<SettingsEntry> GetSettingsEntries()
{
var entries = new List<SettingsEntry>();
foreach (var module in Registry.GetModules())
{
var isModuleEnabled = Registry.IsModuleEnabled(module.ModuleName);
var isExpanded = _expandedModules.Contains(module.ModuleName);
entries.Add(new SettingsEntry(
SettingsEntryKind.Module,
module.ModuleName,
null,
module.ModuleName,
module.Description,
isModuleEnabled,
isModuleEnabled,
isExpanded,
0));
if (!isExpanded)
{
continue;
}
foreach (var toolName in module.ToolNames)
{
var isConfigured = Registry.IsToolConfiguredEnabled(module.ModuleName, toolName);
entries.Add(new SettingsEntry(
SettingsEntryKind.Tool,
module.ModuleName,
toolName,
toolName,
isModuleEnabled
? $"{module.ModuleName} / {toolName}"
: $"{module.ModuleName} / {toolName} {Localization.Current.ModuleOffPreserved}",
isConfigured,
isModuleEnabled,
false,
1));
}
}
return entries;
}
private List<LogEntry> GetFilteredLogEntries()
{
var entries = LogSink.GetEntries();
return _logFilters[_logFilterIndex] switch
{
"Info" => entries.Where(IsInfoLevel).ToList(),
"Warn" => entries.Where(entry => entry.Level == LogLevel.Warning).ToList(),
"Error" => entries.Where(entry => entry.Level is LogLevel.Error or LogLevel.Critical).ToList(),
_ => entries.ToList()
};
}
private void ClampSelections()
{
var overviewRows = GetOverviewRows();
_overviewSelection = overviewRows.Count == 0
? 0
: Math.Clamp(_overviewSelection, 0, overviewRows.Count - 1);
var logEntries = GetFilteredLogEntries();
_logSelection = logEntries.Count == 0
? 0
: Math.Clamp(_logSelection, 0, logEntries.Count - 1);
var settingsEntries = GetSettingsEntries();
_settingsSelection = settingsEntries.Count == 0
? 0
: Math.Clamp(_settingsSelection, 0, settingsEntries.Count - 1);
}
private void OnRegistryChanged()
{
InvokeAsync(() =>
{
ClampSelections();
StateHasChanged();
});
}
private void OnNewLog(LogEntry entry)
{
InvokeAsync(() =>
{
if (_logsStickToBottom && MatchesCurrentLogFilter(entry))
{
var filteredEntries = GetFilteredLogEntries();
_logSelection = Math.Max(filteredEntries.Count - 1, 0);
}
else
{
ClampSelections();
}
StateHasChanged();
});
}
private bool MatchesCurrentLogFilter(LogEntry entry) =>
_logFilters[_logFilterIndex] switch
{
"Info" => IsInfoLevel(entry),
"Warn" => entry.Level == LogLevel.Warning,
"Error" => entry.Level is LogLevel.Error or LogLevel.Critical,
_ => true
};
private static bool IsInfoLevel(LogEntry entry) =>
entry.Level is LogLevel.Information or LogLevel.Debug or LogLevel.Trace;
private string GetTabLabel(Tab tab) => tab switch
{
Tab.Overview => Localization.Current.TabOverview,
Tab.Logs => Localization.Current.TabLogs,
_ => Localization.Current.TabSettings
};
private HeaderSegment[] BuildHeaderSegments()
{
var width = Math.Max(UiMetrics.ConsoleWidth, 1);
var projectSegmentWidth = GetHeaderProjectSegment().Length;
var availableTabsWidth = Math.Max(width - projectSegmentWidth, 0);
if (availableTabsWidth <= 0)
{
return [];
}
var labels = _tabs.Select(GetTabLabel).ToArray();
var labelWidths = labels.Select(label => label.Length).ToArray();
var availableLabelWidth = availableTabsWidth - (_tabs.Length * 2);
if (availableLabelWidth < _tabs.Length)
{
return
[
new HeaderSegment(
FillBar(availableTabsWidth),
UiPalette.Text,
UiPalette.HeaderTabsBackground,
Spectre.Console.Decoration.None)
];
}
while (labelWidths.Sum() > availableLabelWidth)
{
var largestIndex = Array.IndexOf(labelWidths, labelWidths.Max());
if (largestIndex < 0 || labelWidths[largestIndex] <= 1)
{
break;
}
labelWidths[largestIndex]--;
}
var segments = new List<HeaderSegment>(_tabs.Length + 1);
var usedWidth = 0;
for (var i = 0; i < _tabs.Length; i++)
{
var tab = _tabs[i];
var label = FitInline(labels[i], labelWidths[i]);
var content = WrapBarSegment(label);
segments.Add(new HeaderSegment(
content,
tab == _activeTab ? UiPalette.SelectionForeground : UiPalette.Text,
tab == _activeTab ? UiPalette.SelectionBackground : UiPalette.HeaderTabsBackground,
tab == _activeTab ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None));
usedWidth += content.Length;
}
var fillerWidth = Math.Max(availableTabsWidth - usedWidth, 0);
if (fillerWidth > 0)
{
segments.Add(new HeaderSegment(
FillBar(fillerWidth),
UiPalette.Text,
UiPalette.HeaderTabsBackground,
Spectre.Console.Decoration.None));
}
return [.. segments];
}
private string GetHeaderProjectSegment() => WrapBarSegment(Localization.Current.ProjectTitle);
private string GetHelpLine(Tab tab, string hint) => $"{GetTabLabel(tab)}: {hint}";
private string GetStatusBarLeftText() => $"{Localization.Label} | {Localization.Current.HelpStatusHint}";
private string GetStatusBarRightText() => $"{Localization.Current.DashboardEndpointLabel}: {_mcpEndpoint}";
private string BuildStatusBar(int width)
{
var left = GetStatusBarLeftText();
var right = GetStatusBarRightText();
width = Math.Max(width, 1);
if (right.Length >= width)
{
return right[^width..];
}
var leftWidth = Math.Max(width - right.Length - 1, 0);
var fittedLeft = leftWidth == 0 ? string.Empty : PadRightVisible(FitInline(left, leftWidth), leftWidth);
return leftWidth == 0
? PadLeftVisible(right, width)
: $"{fittedLeft} {right}";
}
private static string FillBar(int width) =>
width <= 0 ? string.Empty : new string(Nbsp, width);
private static string WrapBarSegment(string text) => $"{Nbsp}{text}{Nbsp}";
private static string PadRightVisible(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length >= width) return text[..width];
return text + FillBar(width - text.Length);
}
private static string PadLeftVisible(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length >= width) return text[^width..];
return FillBar(width - text.Length) + text;
}
private static string FitInline(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length <= width) return text;
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
public void Dispose()
{
Registry.StateChanged -= OnStateChanged;
Registry.StateChanged -= OnRegistryChanged;
LogSink.OnLog -= OnNewLog;
KeyboardService.OnKeyPressed -= OnConsoleKeyPressed;
Localization.OnChanged -= OnLocaleChanged;
}
}

View File

@@ -1,119 +1,116 @@
@using LazyBear.MCP.Services.Logging
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows>
<Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content=" " />
@* Фильтр по модулю *@
<Columns>
<Markup Content="Filter: " />
<Select TItem="string"
Options="@_filterOptions"
Value="@_selectedFilter"
ValueChanged="@OnFilterChanged"
FocusOrder="10" />
@foreach (var filter in Filters)
{
var isActive = string.Equals(filter, SelectedFilter, StringComparison.Ordinal);
<Markup Content="@($" {FilterDisplay(filter)} ")"
Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
Background="@(isActive ? UiPalette.AccentSoft : UiPalette.SurfaceMuted)"
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
<Markup Content=" " />
}
</Columns>
<Markup Content=" " />
@{
var entries = GetFilteredEntries();
}
@if (entries.Count == 0)
@if (Entries.Count == 0)
{
<Markup Content="[grey]No log entries yet...[/]" />
<Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
<Markup Content="@Loc.LogsEmpty" Foreground="@UiPalette.TextDim" />
</Border>
}
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>
<Select TItem="int"
Options="@GetOptions()"
Value="@GetNormalizedIndex()"
FocusedValue="@GetNormalizedIndex()"
Formatter="@FormatEntry"
Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " />
<Markup Content="@GetDetailsHeader()" Foreground="@UiPalette.TextMuted" />
<Markup Content="@GetDetailsText()" Foreground="@UiPalette.Text" />
</Rows>
@code {
private string _selectedFilter = "All";
private int _scrollOffset = 0;
// Внутренние ключи фильтров — не локализуются (используются в логике App.razor)
private static readonly string[] Filters = ["All", "Info", "Warn", "Error"];
private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"];
[Parameter, EditorRequired] public IReadOnlyList<LogEntry> Entries { get; set; } = Array.Empty<LogEntry>();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public string SelectedFilter { get; set; } = "All";
[Parameter] public int ViewportRows { get; set; } = 5;
[Parameter] public bool IsStickyToBottom { get; set; }
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private static readonly Dictionary<string, string?> FilterPrefixes = new()
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
// "All" локализуется; уровни логов (Info/Warn/Error) остаются на английском в любой локали
private string FilterDisplay(string filter) =>
filter == "All" ? Loc.FilterAll : filter;
private string GetDetailsHeader()
{
["All"] = null,
["Jira"] = "LazyBear.MCP.Services.Jira",
["Kubernetes"] = "LazyBear.MCP.Services.Kubernetes",
["Confluence"] = "LazyBear.MCP.Services.Confluence",
["MCP"] = "ModelContextProtocol",
["System"] = "Microsoft"
};
if (Entries.Count == 0)
{
return $"{Loc.FilterLabel}: {FilterDisplay(SelectedFilter)}";
}
private IReadOnlyList<LogEntry> GetFilteredEntries()
{
FilterPrefixes.TryGetValue(_selectedFilter, out var prefix);
return LogSink.GetEntries(prefix);
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
var position = Math.Clamp(SelectedIndex, 0, Entries.Count - 1) + 1;
var sticky = IsStickyToBottom ? Loc.LogsSticky : Loc.LogsManual;
return $"{position}/{Entries.Count} | {selected.Timestamp:HH:mm:ss} | {selected.Level} | {selected.ShortCategory} | {sticky}";
}
private void OnFilterChanged(string value)
private string GetDetailsText()
{
_selectedFilter = value;
_scrollOffset = 0;
StateHasChanged();
if (Entries.Count == 0)
{
return Loc.LogsPlaceholder;
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
var details = string.IsNullOrWhiteSpace(selected.Exception)
? selected.Message
: $"{selected.Message} | {selected.Exception}";
return Fit(details, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
protected override void OnInitialized()
private string FormatEntry(int index)
{
LogSink.OnLog += HandleNewLog;
var entry = Entries[index];
var level = entry.Level switch
{
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
LogLevel.Warning => "WRN",
LogLevel.Information => "INF",
LogLevel.Debug => "DBG",
_ => "TRC"
};
var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private void HandleNewLog(LogEntry _)
private static string Fit(string text, int width)
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
LogSink.OnLog -= HandleNewLog;
if (width <= 0) return string.Empty;
if (text.Length <= width) return text.PadRight(width);
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
}

View File

@@ -1,39 +1,65 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows>
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<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>
@if (Rows.Count == 0)
{
<Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
<Markup Content="@Loc.OverviewEmpty" Foreground="@UiPalette.TextDim" />
</Border>
}
else
{
<Select TItem="int"
Options="@GetOptions()"
Value="@GetNormalizedIndex()"
FocusedValue="@GetNormalizedIndex()"
Formatter="@FormatRow"
Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " />
<Markup Content="[grey]Go to Settings tab to toggle modules and tools[/]" />
<Markup Content="@GetFooterText()" Foreground="@UiPalette.TextMuted" />
</Rows>
@code {
[Parameter, EditorRequired] public IReadOnlyList<OverviewRow> Rows { get; set; } = Array.Empty<OverviewRow>();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 3;
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray();
private int GetNormalizedIndex() => Rows.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Rows.Count - 1);
private string GetFooterText()
{
if (Rows.Count == 0)
{
return Loc.OverviewEmpty;
}
var selected = Rows[Math.Clamp(SelectedIndex, 0, Rows.Count - 1)];
var state = selected.IsModuleEnabled ? Loc.StateOn : Loc.StateOff;
return $"{selected.ModuleName}: {selected.Description} | {Loc.FooterModule} {state} | {Loc.FooterTools} {selected.ConfiguredTools}/{selected.TotalTools}";
}
private string FormatRow(int index)
{
var row = Rows[index];
var status = row.IsModuleEnabled ? $"[{Loc.StateOn}] " : $"[{Loc.StateOff}]";
var text = $"{row.ModuleName,-12} {status} {row.ConfiguredTools,2}/{row.TotalTools,-2} {row.Description}";
return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private static string Fit(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length <= width) return text.PadRight(width);
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
}

View File

@@ -1,42 +1,79 @@
@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="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content=" " />
@{
int focusIdx = 20;
}
@foreach (var module in Registry.GetModules())
@if (Entries.Count == 0)
{
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=" " />
<Border BorderColor="@UiPalette.Frame" BoxBorder="@Spectre.Console.BoxBorder.Rounded" Padding="@(new Spectre.Console.Padding(0, 0, 0, 0))">
<Markup Content="@Loc.SettingsEmpty" Foreground="@UiPalette.TextDim" />
</Border>
}
else
{
<Select TItem="int"
Options="@GetOptions()"
Value="@GetNormalizedIndex()"
FocusedValue="@GetNormalizedIndex()"
Formatter="@FormatEntry"
Expand="true"
BorderStyle="@Spectre.Console.BoxBorder.Rounded"
SelectedIndicator="@('>')" />
}
<Markup Content=" " />
<Markup Content="@GetSelectedDescription()" Foreground="@UiPalette.TextMuted" />
</Rows>
@code {
[Parameter, EditorRequired] public IReadOnlyList<SettingsEntry> Entries { get; set; } = Array.Empty<SettingsEntry>();
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 5;
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Entries.Count).ToArray();
private int GetNormalizedIndex() => Entries.Count == 0 ? 0 : Math.Clamp(SelectedIndex, 0, Entries.Count - 1);
private string GetSelectedDescription()
{
if (Entries.Count == 0)
{
return Loc.SettingsUnavailable;
}
var selected = Entries[Math.Clamp(SelectedIndex, 0, Entries.Count - 1)];
return selected.Description;
}
private string FormatEntry(int index)
{
var entry = Entries[index];
var indent = new string(' ', entry.Depth * 4);
var checkbox = entry.IsChecked ? "[x]" : "[ ]";
var disabledSuffix = entry.Kind == SettingsEntryKind.Tool && !entry.IsModuleEnabled
? $" {Loc.ModuleOff}"
: string.Empty;
string text;
if (entry.Kind == SettingsEntryKind.Module)
{
var expander = entry.IsExpanded ? "[-]" : "[+]";
text = $"{expander} {checkbox} {entry.Label}";
}
else
{
text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}";
}
return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private static string Fit(string text, int width)
{
if (width <= 0) return string.Empty;
if (text.Length <= width) return text.PadRight(width);
if (width <= 3) return text[..width];
return text[..(width - 3)] + "...";
}
}

View File

@@ -8,9 +8,9 @@
var moduleName = Module.ModuleName;
var fo = StartFocusIdx + idx;
<TextButton Content="@(toolEnabled ? $"[green]✓[/] {toolName}" : $"[grey]✗[/] {toolName}")"
<TextButton Content="@(toolEnabled ? $" {toolName}" : $" {toolName}")"
OnClick="@(() => Registry.ToggleTool(moduleName, toolName))"
BackgroundColor="@(toolEnabled ? Spectre.Console.Color.Grey19 : Spectre.Console.Color.Grey7)"
BackgroundColor="@(toolEnabled ? Spectre.Console.Color.DarkGreen : Spectre.Console.Color.Grey11)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@fo" />
}

View File

@@ -1,5 +1,10 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Hosting
@using RazorConsole.Components
@using RazorConsole.Core
@using RazorConsole.Core.Rendering
@using LazyBear.MCP.TUI
@using LazyBear.MCP.TUI.Localization
@using LazyBear.MCP.TUI.Models
@using LazyBear.MCP.TUI.Components

View File

@@ -0,0 +1,74 @@
using Microsoft.Extensions.Hosting;
namespace LazyBear.MCP.TUI;
/// <summary>
/// Фоновый сервис для глобального чтения клавиш консоли.
/// Единственный источник клавишных событий для всего TUI.
///
/// Использует выделенный поток с блокирующим Console.ReadKey — никакого
/// polling-а, нет обращений к Console.KeyAvailable, которые захватывают
/// консольный mutex и мешают рендерингу RazorConsole.
/// </summary>
public sealed class GlobalKeyboardService : IHostedService, IDisposable
{
public event Action<ConsoleKeyInfo>? OnKeyPressed;
private Thread? _thread;
private volatile bool _stopping;
public Task StartAsync(CancellationToken cancellationToken)
{
if (Console.IsInputRedirected)
{
return Task.CompletedTask;
}
_thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "KeyboardReader"
};
_thread.Start();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_stopping = true;
// Console.ReadKey не поддерживает CancellationToken — поток
// завершится сам при выходе приложения (IsBackground = true).
return Task.CompletedTask;
}
private void ReadLoop()
{
while (!_stopping)
{
try
{
// Блокирующий вызов: не нагружает CPU и не трогает консольный
// mutex в паузах между нажатиями — рендеринг не страдает.
var key = Console.ReadKey(intercept: true);
if (!_stopping)
{
OnKeyPressed?.Invoke(key);
}
}
catch (InvalidOperationException)
{
// stdin стал недоступен (перенаправление и т.п.)
break;
}
catch
{
Thread.Sleep(100);
}
}
}
public void Dispose()
{
_stopping = true;
}
}

View File

@@ -0,0 +1,7 @@
namespace LazyBear.MCP.TUI.Localization;
public enum Locale
{
En = 0,
Ru = 1
}

View File

@@ -0,0 +1,25 @@
namespace LazyBear.MCP.TUI.Localization;
/// <summary>
/// Синглтон, хранящий текущую локаль TUI. Переключение — клавиша L.
/// Компоненты подписываются на OnChanged для перерисовки при смене языка.
/// </summary>
public sealed class LocalizationService
{
private static readonly TuiResources[] All = [TuiResources.En, TuiResources.Ru];
private static readonly string[] Labels = ["EN", "RU"];
private int _index;
public TuiResources Current => All[_index];
public string Label => Labels[_index];
public Locale Locale => (Locale)_index;
public event Action? OnChanged;
public void SwitchNext()
{
_index = (_index + 1) % All.Length;
OnChanged?.Invoke();
}
}

View File

@@ -0,0 +1,125 @@
namespace LazyBear.MCP.TUI.Localization;
/// <summary>
/// Все строки TUI для одной локали.
/// При добавлении новой строки: добавить свойство сюда и перевод в оба статических экземпляра.
/// </summary>
public sealed record TuiResources
{
// ── Подсказка и вкладки ──────────────────────────────────────────────────
public string ProjectTitle { get; init; } = "";
public string HintBar { get; init; } = "";
public string TabOverview { get; init; } = "";
public string TabLogs { get; init; } = "";
public string TabSettings { get; init; } = "";
public string HelpButtonLabel { get; init; } = "";
public string HelpStatusHint { get; init; } = "";
public string HelpModalTitle { get; init; } = "";
public string HelpCloseHint { get; init; } = "";
// ── Dashboard ────────────────────────────────────────────────────────────
public string OverviewTitle { get; init; } = "";
public string OverviewHint { get; init; } = "";
public string OverviewEmpty { get; init; } = "";
public string DashboardEndpointLabel { get; init; } = "";
public string StateOn { get; init; } = "";
public string StateOff { get; init; } = "";
public string FooterModule { get; init; } = "";
public string FooterTools { get; init; } = "";
// ── Logs ─────────────────────────────────────────────────────────────────
public string LogsTitle { get; init; } = "";
public string LogsHint { get; init; } = "";
public string LogsEmpty { get; init; } = "";
public string LogsPlaceholder { get; init; } = "";
public string LogsSticky { get; init; } = "";
public string LogsManual { get; init; } = "";
public string FilterLabel { get; init; } = "";
public string FilterAll { get; init; } = "";
// ── Settings ─────────────────────────────────────────────────────────────
public string SettingsTitle { get; init; } = "";
public string SettingsHint { get; init; } = "";
public string SettingsEmpty { get; init; } = "";
public string SettingsUnavailable { get; init; } = "";
public string ModuleOff { get; init; } = "";
public string ModuleOffPreserved { get; init; } = "";
// ── Локали ───────────────────────────────────────────────────────────────
public static readonly TuiResources En = new()
{
ProjectTitle = "LazyBear MCP",
HintBar = "Tab/Shift+Tab: switch tabs | H: help | L: language | Q: quit",
TabOverview = "Dashboard",
TabLogs = "Logs",
TabSettings = "Settings",
HelpButtonLabel = "Help",
HelpStatusHint = "H: help",
HelpModalTitle = "Keyboard Shortcuts",
HelpCloseHint = "H / Esc: close",
OverviewTitle = "Dashboard",
OverviewHint = "Up/Down: select module. Enter: open settings.",
OverviewEmpty = "No modules registered.",
DashboardEndpointLabel = "MCP Endpoint",
StateOn = "ON",
StateOff = "OFF",
FooterModule = "Module",
FooterTools = "Tools",
LogsTitle = "Runtime Logs",
LogsHint = "Left/Right: filter | Up/Down: scroll | PageUp/Down: page",
LogsEmpty = "No log entries yet.",
LogsPlaceholder = "Incoming log entries will appear here.",
LogsSticky = "sticky",
LogsManual = "manual",
FilterLabel = "Filter",
FilterAll = "All",
SettingsTitle = "Tool Registry",
SettingsHint = "Up/Down: select | Left/Right: expand/collapse | Space: toggle",
SettingsEmpty = "No modules available.",
SettingsUnavailable = "Runtime enable/disable settings are unavailable.",
ModuleOff = "(module off)",
ModuleOffPreserved = "(module is OFF, tool state is preserved)"
};
public static readonly TuiResources Ru = new()
{
ProjectTitle = "LazyBear MCP",
HintBar = "Tab/Shift+Tab: вкладки | H: справка | L: язык | Q: выход",
TabOverview = "Dashboard",
TabLogs = "Логи",
TabSettings = "Настройки",
HelpButtonLabel = "Справка",
HelpStatusHint = "H: справка",
HelpModalTitle = "Горячие клавиши",
HelpCloseHint = "H / Esc: закрыть",
OverviewTitle = "Dashboard",
OverviewHint = "Вверх/вниз: выбор модуля. Enter: открыть настройки.",
OverviewEmpty = "Нет зарегистрированных модулей.",
DashboardEndpointLabel = "MCP Endpoint",
StateOn = "ВКЛ",
StateOff = "ВЫКЛ",
FooterModule = "Модуль",
FooterTools = "Инструменты",
LogsTitle = "Логи",
LogsHint = "Лево/право: фильтр | Вверх/вниз: прокрутка | PageUp/Down: страница",
LogsEmpty = "Записей пока нет.",
LogsPlaceholder = "Новые записи будут появляться здесь.",
LogsSticky = "следить",
LogsManual = "вручную",
FilterLabel = "Фильтр",
FilterAll = "Все",
SettingsTitle = "Реестр инструментов",
SettingsHint = "Вверх/вниз: выбор | Лево/право: развернуть/свернуть | Space: вкл/выкл",
SettingsEmpty = "Нет доступных модулей.",
SettingsUnavailable = "Настройки включения/выключения недоступны.",
ModuleOff = "(модуль выкл)",
ModuleOffPreserved = "(модуль ВЫКЛ, состояние инструментов сохранено)"
};
}

View File

@@ -0,0 +1,8 @@
namespace LazyBear.MCP.TUI.Models;
public sealed record OverviewRow(
string ModuleName,
string Description,
bool IsModuleEnabled,
int ConfiguredTools,
int TotalTools);

View File

@@ -0,0 +1,18 @@
namespace LazyBear.MCP.TUI.Models;
public enum SettingsEntryKind
{
Module,
Tool
}
public sealed record SettingsEntry(
SettingsEntryKind Kind,
string ModuleName,
string? ToolName,
string Label,
string Description,
bool IsChecked,
bool IsModuleEnabled,
bool IsExpanded,
int Depth);

View File

@@ -1,69 +0,0 @@
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");
}
}
}

View File

@@ -0,0 +1,43 @@
using Spectre.Console;
namespace LazyBear.MCP.TUI;
internal static class UiMetrics
{
public static int ConsoleWidth => Math.Max(ReadConsoleSize(ReadConsoleWidth, () => AnsiConsole.Profile.Width, 80), 1);
public static int ConsoleHeight => Math.Max(ReadConsoleSize(ReadConsoleHeight, () => AnsiConsole.Profile.Height, 24), 1);
private static int ReadConsoleSize(Func<int> consoleReader, Func<int> profileReader, int fallback)
{
try
{
var consoleValue = consoleReader();
if (consoleValue > 0)
{
return consoleValue;
}
}
catch
{
// Игнорируем и пробуем fallback через профиль Spectre.
}
try
{
var profileValue = profileReader();
if (profileValue > 0)
{
return profileValue;
}
}
catch
{
// Игнорируем и используем значение по умолчанию.
}
return fallback;
}
private static int ReadConsoleWidth() => Console.WindowWidth;
private static int ReadConsoleHeight() => Console.WindowHeight;
}

View File

@@ -0,0 +1,23 @@
using Spectre.Console;
namespace LazyBear.MCP.TUI;
internal static class UiPalette
{
public static readonly Color Frame = new(26, 44, 64);
public static readonly Color Surface = new(12, 21, 34);
public static readonly Color SurfaceAlt = new(18, 29, 44);
public static readonly Color SurfaceMuted = new(28, 40, 56);
public static readonly Color HeaderBrandBackground = new(9, 31, 47);
public static readonly Color HeaderTabsBackground = SurfaceMuted;
public static readonly Color Accent = Color.Cyan1;
public static readonly Color AccentSoft = Color.DeepSkyBlue1;
public static readonly Color Text = Color.Grey93;
public static readonly Color TextMuted = Color.Grey62;
public static readonly Color TextDim = Color.Grey46;
public static readonly Color Success = Color.Green3;
public static readonly Color Warning = Color.Yellow3;
public static readonly Color Danger = Color.Red3;
public static readonly Color SelectionBackground = new(24, 152, 181);
public static readonly Color SelectionForeground = new(7, 18, 31);
}

View File

@@ -14,6 +14,16 @@
"Username": "",
"SpaceKey": ""
},
"GitLab": {
"Url": "",
"Token": "",
"Project": ""
},
"Qdrant": {
"Url": "",
"ApiKey": "",
"DefaultCollection": "knowledge"
},
"Logging": {
"LogLevel": {
"Default": "Information",

77
LazyBear.MCP/logo.svg Normal file
View File

@@ -0,0 +1,77 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#050816"/>
<stop offset="100%" stop-color="#0E0A22"/>
</linearGradient>
<linearGradient id="cyan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#F2FFFF"/>
<stop offset="50%" stop-color="#3EE8FF"/>
<stop offset="100%" stop-color="#0A8FE5"/>
</linearGradient>
<linearGradient id="visor" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#C7FFFF"/>
<stop offset="100%" stop-color="#9B6CFF"/>
</linearGradient>
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="8" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- background -->
<rect width="1024" height="1024" rx="220" fill="url(#bg)"/>
<!-- BIGGER BEAR (scaled + centered) -->
<g transform="translate(512,560) scale(1.25) translate(-512,-560)">
<!-- ears -->
<circle cx="360" cy="300" r="90" fill="#0A101B" stroke="#4EE7FF" stroke-width="14"/>
<circle cx="664" cy="300" r="90" fill="#0A101B" stroke="#4EE7FF" stroke-width="14"/>
<!-- head -->
<path d="M260 380
C260 240 380 160 512 160
C644 160 764 240 764 380
C764 560 650 760 512 840
C374 760 260 560 260 380Z"
fill="#0A101B" stroke="#54E8FF" stroke-width="16"/>
<!-- face -->
<path d="M340 430
C360 330 430 290 512 290
C594 290 664 330 684 430
C694 500 660 610 590 670
C550 700 474 700 434 670
C364 610 330 500 340 430Z"
fill="url(#cyan)"/>
<!-- visor -->
<g filter="url(#glow)">
<path d="M360 370
C400 330 624 330 664 370
L664 450
C620 480 404 480 360 450Z"
fill="#0B1528" stroke="url(#visor)" stroke-width="12"/>
</g>
<!-- nose -->
<ellipse cx="512" cy="500" rx="40" ry="26" fill="#0E121A"/>
<!-- simplified mouth for small sizes -->
<path d="M440 580
C470 610 554 610 584 580"
fill="none" stroke="#FFFFFF" stroke-width="10" stroke-linecap="round"/>
<!-- teeth (simplified) -->
<path d="M470 585 L490 615 L510 585 L530 615 L550 585"
fill="none" stroke="#EFFFFF" stroke-width="8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

168
README.md
View File

@@ -2,7 +2,7 @@
![LazyBear Logo](logo.png)
**.NET 10 сервер Model Context Protocol (MCP) для интеграции с Jira, Confluence и Kubernetes.**
**.NET 10 сервер Model Context Protocol (MCP) для интеграции с Jira, Confluence, Kubernetes и GitLab.**
---
@@ -13,32 +13,38 @@
| 📋 **Jira** | Работа с задачами, JQL, комментариями | ✅ Доступно |
| 📄 **Confluence** | Работа со страницами и пространствами | ✅ Доступно |
| ☸️ **Kubernetes** | Управление деплоями, подами, сетями | ✅ Доступно |
| 🌳 **GitLab** | Работа с репозиториями, MR, Issue, ветками, тегами | ✅ Доступно |
| 🖥️ **TUI** | Интерактивная консольная панель | ✅ Доступно |
| 🌐 **Localization** | Многоязычный интерфейс (RU/EN) | ✅ Доступно |
---
## 🏗️ Архитектура
```
┌───────────────────────────────────────────────────────────┐
┌───...──────────────────────────────────────────────────────┐
│ HTTP Transport Layer (ASP.NET Core) │
│ └── ModelContextProtocol 1.2.0 HTTP транспорт │
└───────────────────────────────────────────────────────────┘
└───...──────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
┌───...──────────────────────────────────────────────────────┐
│ Application Layer (Razor Pages UI) │
│ └── Web-страницы для мониторинга K8s │
└───────────────────────────────────────────────────────────┘
└───...──────────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────────────┐
Kubernetes Layer (Kubernetes Client)
── Подключение к K8s API сервер через kubeconfig
└───────────────────────────────────────────────────────────┘
┌───...──────────────────────────────────────────────────────┐
Integration Layers
── Jira API (REST)
│ ├── Confluence API (REST) │
│ ├── Kubernetes API (REST) │
│ └── GitLab API (REST) │
└───...──────────────────────────────────────────────────────┘
```
**Потоки данных:**
1. **Initialize Flow**: Клиент → HTTP → Application → K8s Client → K8s API
2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s] → Возврат результата
1. **Initialize Flow**: Клиент → HTTP → Application → Integration Client → API
2. **Tools Flow**: Клиент → RPC → Tools → [Jira/Confluence/K8s/GitLab] → Возврат результата
3. **Health Check Flow**: `/health` → HTTP → Liveness probe → Status
---
@@ -49,6 +55,7 @@
- .NET 10 SDK
- Kubectl и kubeconfig
- GitLab Personal Access Token (опционально)
- Docker Desktop (опционально)
### Запуск
@@ -123,11 +130,7 @@ docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp
"params": {
"spaceKey": "LAZYBEAR",
"title": "Инструкция по развёртыванию",
"body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий...",
"parentPageId": null
}
}
```
"body": "# Инструкция по развёртыванию\n\nШаг 1: Клонируйте репозиторий.
---
@@ -174,21 +177,83 @@ docker run -p 5000:5000 -v $HOME/.kube:/root/.kube:ro lazybear-mcp
}
```
---
### 🌳 GitLab
Работа с GitLab API для управления репозиториями, MR, Issue, ветками и тегами.
**Методы:**
**Репозитории:**
- `list_projects` Список всех репозиториев
- `get_project` Информация о репозитории по ID/path
**Теги (Версии):**
- `list_versions` Список тегов репозитория
- `create_version` Создание нового тега
- `delete_version` Удаление тега
**Merge Requests:**
- `list_merge_requests` Список всех MR
- `get_merge_request` Информация о конкретном MR
- `create_merge_request` Создание MR
- `close_merge_request` Закрытие MR
- `open_merge_request` Открытие MR
- `list_merge_request_notes` Замечания к MR
- `create_merge_request_note` Добавление замечания
- `delete_merge_request_note` Удаление замечания
**Issues:**
- `list_issues` Список Issues
- `list_issues_simple` Быстрый список Issues
- `get_issue` Информация об Issue
- `create_issue` Создание Issue
- `update_issue` Обновление Issue
- `close_issue` Закрытие Issue
- `open_issue` Открытие Issue
- `list_issue_notes` Замечания к Issue
- `create_issue_note` Добавление замечания
- `delete_issue_note` Удаление замечания
**Ветки:**
- `list_branches` Список веток
- `get_branch` Информация о ветке
- `create_branch` Создание ветки
- `delete_branch` Удаление ветки
- `protect_branch` Защита ветки
- `unprotect_branch` Удаление защиты
**Примеры вызова:**
```json
{
"method": "k8sNetworkTools/createService",
"method": "gitlabTools/list_projects",
"params": {}
}
```
```json
{
"method": "gitlabTools/create_merge_request",
"params": {
"name": "nginx-svc",
"type": "ClusterIP",
"port": 80
"sourceBranch": "feature-xyz",
"targetBranch": "main",
"title": "Add new feature",
"description": "Implements new feature xyz"
}
}
```
```json
{
"method": "k8sPodsTools/getPodStatus",
"params": { "name": "nginx-pod-abc123" }
"method": "gitlabTools/create_issue",
"params": {
"title": "Fix production bug",
"description": "Critical bug in production environment",
"assigneeId": 123,
"labels": ["bug", "critical"]
}
}
```
@@ -207,13 +272,24 @@ LazyBear.MCP/
│ │ └── JiraIssueTools.cs # Инструменты для Jira
│ ├── Confluence/
│ │ └── ConfluencePagesTools.cs # Инструменты для Confluence
── Kubernetes/
├── K8sConfigTools.cs # Инструменты конфигурации
├── K8sDeploymentTools.cs # Инструменты деплоя
├── K8sNetworkTools.cs # Инструменты сети
├── K8sPodsTools.cs # Инструменты подов
├── K8sClientFactory.cs # Factory для клиентов
└── K8sClientProvider.cs # Provider для клиентов
── Kubernetes/
├── K8sConfigTools.cs # Инструменты конфигурации
├── K8sDeploymentTools.cs # Инструменты деплоя
├── K8sNetworkTools.cs # Инструменты сети
├── K8sPodsTools.cs # Инструменты подов
├── K8sClientFactory.cs # Factory для клиентов
└── K8sClientProvider.cs # Provider для клиентов
│ └── GitLab/
│ ├── GitLabToolModule.cs # Регистрация инструментов
│ ├── GitLabToolsBase.cs # Базовый класс с common-методами
│ ├── GitLabApiClient.cs # REST клиент (RestSharp)
│ ├── GitLabClientProvider.cs # Provider
│ ├── GitLabClientFactory.cs # Factory
│ ├── GitLabRepositoryTools.cs # Репозитории
│ ├── GitLabVersionTools.cs # Теги
│ ├── GitLabMergeRequestTools.cs # MR
│ ├── GitLabIssueTools.cs # Issues
│ └── GitLabBranchTools.cs # Ветки
├── appsettings.json # Конфиг
└── global.json # Пин SDK
```
@@ -223,13 +299,13 @@ LazyBear.MCP/
## 🖥️ Интерактивная панель
```
┌─────────────────────────────────────────┐
┌─...──────────────────────────────────────────────┐
│ Dashboard: Обзор состояния кластера │
├─────────────────────────────────────────┤
├─...──────────────────────────────────────────────┤
│ Logs & Events: Журналы событий │
│ Containers & Images: Контейнеры │
│ Workloads & Nodes: Распределение │
└─────────────────────────────────────────┘
└─...──────────────────────────────────────────────┘
```
**Настройка в appsettings.json:**
@@ -240,6 +316,20 @@ LazyBear.MCP/
"KubeconfigPath": "~/.kube/config",
"DefaultNamespace": "default"
},
"Jira": {
"Url": "https://jira.example.com",
"Token": "your_jira_token",
"Project": ""
},
"Confluence": {
"Url": "https://confluence.example.com",
"Token": "your_confluence_token"
},
"GitLab": {
"Url": "https://gitlab.com",
"Token": "your_gitlab_pat",
"Project": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -347,6 +437,7 @@ npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
- **UI:** Razor Pages
- **DB:** SQLite/SQL Server
- **Protocol:** Model Context Protocol (MCP)
- **API Clients:** RestSharp (для Jira, Confluence, GitLab), Kubernetes Client (для K8s)
**Документация:**
- **Сгенерированный API**: `/swagger` — Swagger UI
@@ -365,4 +456,15 @@ using Microsoft.OpenApi.Models;
var config = new OpenApiInfo { Title = "LazyBear MCP Server", Version = "1.0.0" };
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", config));
```
```
---
## 📚 Ссылки
- [GitLab API Documentation](https://docs.gitlab.com/ee/api/)
- [MCP Specification](https://modelcontextprotocol.io)
---
*Встроенная документация по MCP*

192
docs/tui_log.md Normal file
View File

@@ -0,0 +1,192 @@
# TUI Keyboard Handling — разбор и решение
## Проблема
После внедрения RazorConsole TUI не работали два базовых взаимодействия:
- **Tab** не переключал вкладки (Overview / Logs / Settings)
- **Стрелки** не навигировали по спискам
## Диагностика
### Что проверяли
Изучили исходную архитектуру: каждый дочерний компонент (`OverviewTab`, `LogsTab`, `SettingsTab`) рендерил `<Select TItem="int">` из `RazorConsole.Components` с атрибутом `@onkeydown="OnKeyDown"`. `OnKeyDown` — это `EventCallback<KeyboardEventArgs>`, пробрасываемый из `App.razor`.
### Корневая причина — RazorConsole 0.5.0
Изучили пакет `RazorConsole.Core 0.5.0` (XML-документация, структура NuGet):
1. **`@onkeydown` на `<Select>` не работает для служебных клавиш.**
`Select` получает `@onkeydown` как часть `AdditionalAttributes`. Стрелки и Enter компонент обрабатывает внутренне (переключает `FocusedValue`), наружу через callback они не выходят.
2. **Tab перехватывается FocusManager до `<Select>`.**
Внутренняя инфраструктура RazorConsole вызывает `FocusManager.FocusNextAsync()` при нажатии Tab ещё до того, как событие доходит до компонента. Наш хендлер в `App.razor` не вызывался вообще.
3. **Глобального перехвата клавиш в 0.5.0 нет.**
`ConsoleAppOptions` содержит только три поля (`AutoClearConsole`, `EnableTerminalResizing`, `AfterRenderAsync`) — никаких `OnKeyPress` / `IKeyboardHandler`. Публичного API для регистрации глобального обработчика не предусмотрено.
4. **`Select.FocusedValue` vs `Select.Value`.**
`Value` / `ValueChanged` — «зафиксированный» выбор, обновляется только при подтверждении (Enter). `FocusedValue` / `FocusedValueChanged` — подсветка при навигации стрелками. Мы подписывались на `ValueChanged`, поэтому даже если стрелки что-то делали внутри Select — наш стейт не обновлялся.
## Решение
### GlobalKeyboardService
Создали `LazyBear.MCP/TUI/GlobalKeyboardService.cs` — единственный источник клавишных событий для всего TUI. Реализует `IHostedService` (не `BackgroundService`), запускает **выделенный фоновый поток** с блокирующим `Console.ReadKey(intercept: true)`.
Зарегистрирован как `Singleton + IHostedService` в `Program.cs`, чтобы можно было инжектить в компоненты:
```csharp
services.AddSingleton<GlobalKeyboardService>();
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
```
### App.razor
- Убраны `FocusManager` и `OnAfterRenderAsync` (FocusManager мешал: перехватывал Tab для собственного `FocusNextAsync`).
- Подписка на `KeyboardService.OnKeyPressed` в `OnInitialized`, отписка в `Dispose`.
- `ConvertKey(ConsoleKeyInfo)` конвертирует нажатие в `KeyboardEventArgs` с именами в стиле браузера (`"ArrowUp"`, `"Tab"`, `" "` и т.д.).
- Вся логика обработки (`HandleOverviewKey`, `HandleLogsKey`, `HandleSettingsKey`) осталась без изменений — переехала из `async Task` в синхронный `void`, `StateHasChanged()` вызывается один раз в конце через `InvokeAsync`.
### Дочерние компоненты
Из `OverviewTab`, `LogsTab`, `SettingsTab`:
- Удалён параметр `OnKeyDown`
- Удалён `@onkeydown="OnKeyDown"` с `<Select>`
- Добавлен `FocusedValue="@GetNormalizedIndex()"` — чтобы индикатор `>` отображался рядом с текущим элементом, управляемым извне
## Проблема производительности (тормоза)
Первая реализация `GlobalKeyboardService` использовала polling:
```csharp
// ❌ Было — 100 вызовов/сек Console.KeyAvailable
while (!stoppingToken.IsCancellationRequested)
{
if (Console.KeyAvailable)
OnKeyPressed?.Invoke(Console.ReadKey(intercept: true));
else
await Task.Delay(10, stoppingToken);
}
```
`Console.KeyAvailable` под капотом захватывает консольный mutex каждые 10ms. RazorConsole для рендеринга тоже обращается к консоли — возникала конкуренция, UI тормозил.
**Исправление** — блокирующий поток без polling:
```csharp
// ✓ Стало — выделенный поток, спит на уровне ОС между нажатиями
_thread = new Thread(ReadLoop) { IsBackground = true, Name = "KeyboardReader" };
void ReadLoop() {
while (!_stopping)
OnKeyPressed?.Invoke(Console.ReadKey(intercept: true)); // блокирует без mutex-а
}
```
Между нажатиями поток блокируется на уровне ОС, консольный mutex свободен, рендеринг не конкурирует ни за какой ресурс.
## Итоговые изменения
| Файл | Что сделано |
|------|-------------|
| `TUI/GlobalKeyboardService.cs` | Создан. Блокирующий ReadKey в выделенном потоке |
| `Program.cs` | Регистрация `GlobalKeyboardService` как singleton + hosted service |
| `TUI/Components/App.razor` | Убран FocusManager. Подписка на GlobalKeyboardService. Sync key handler |
| `TUI/Components/OverviewTab.razor` | Убран OnKeyDown. Добавлен FocusedValue |
| `TUI/Components/LogsTab.razor` | Убран OnKeyDown. Добавлен FocusedValue |
| `TUI/Components/SettingsTab.razor` | Убран OnKeyDown. Добавлен FocusedValue |
| `TUI/Components/_Imports.razor` | Убран `@using RazorConsole.Core.Focus` |
| `InspectRazorConsole/` | Временный проект для инспекции DLL (можно удалить) |
## Вывод по RazorConsole 0.5.0
Фреймворк хорошо подходит для рендеринга (Spectre.Console под капотом), но клавишные события — слабое место. `@onkeydown` на интерактивных компонентах не даёт полного контроля: служебные клавиши перехватываются внутри. Для любого нетривиального TUI с собственной навигацией нужен независимый читатель консольного ввода — либо свой поток с `Console.ReadKey`, либо (если появится) публичный API глобального перехвата в будущих версиях фреймворка.
---
## Ключевые моменты при работе с RazorConsole (0.5.0)
Справочник для написания кода и диагностики багов.
### Архитектура и рендеринг
- **Рендеринг — Spectre.Console под капотом.** Компоненты транслируются через VDOM-слой в `IRenderable` объекты Spectre. Всё, что умеет Spectre.Console (цвета, границы, таблицы, разметка), доступно через Razor-компоненты.
- **`AutoClearConsole = true` в `ConsoleAppOptions`** — обязательно, иначе при перерисовке вывод накапливается и экран «растёт». Устанавливается через `services.Configure<ConsoleAppOptions>(...)` внутри `UseRazorConsole`.
- **`EnableTerminalResizing = true`** — без этого изменение размера окна не триггерит перерисовку. Нужно для корректного поведения `Console.WindowHeight` в вычислениях viewport.
- **`AfterRenderAsync`** — хук после каждого рендера. Единственное место, где гарантированно можно записать в stdout после того, как RazorConsole завершил свой вывод. Используется, например, чтобы скрыть курсор (`Console.CursorVisible = false` + ANSI `\e[?25l`).
- **`StateHasChanged()` нужно вызывать через `InvokeAsync`** при вызове из фонового потока или подписчика события. Иначе — исключение о вызове не из диспетчера компонента:
```csharp
// ✓ из любого потока
InvokeAsync(() => { /* изменить стейт */ StateHasChanged(); });
```
- **Частые `StateHasChanged` → видимые тормоза.** Каждый вызов — полная перерисовка терминала (очистка + вывод). При высокочастотных событиях (потоковые логи, таймеры) нужна дебаунс-логика или throttle: планировать один рендер на «пакет» событий, а не по одному на каждое.
### Компоненты
#### `<Select TItem="T">`
- **`Value` / `ValueChanged`** — зафиксированный выбор. Обновляется только когда пользователь «подтверждает» (Enter). Не подходит для отслеживания позиции курсора в реальном времени.
- **`FocusedValue` / `FocusedValueChanged`** — текущая подсвеченная строка при навигации стрелками. Нужно использовать именно этот параметр для синхронизации позиции курсора с внешним стейтом.
- **`SelectedIndicator`** — символ рядом с текущей `FocusedValue`. Без явной установки `FocusedValue` индикатор может не отображаться или «застывать» на первом элементе.
- **`@onkeydown` на `<Select>` не работает для служебных клавиш** (Tab, Arrow, Enter, Space). Эти клавиши потребляются компонентом или FocusManager до того, как попадают в callback. Не использовать `@onkeydown` на `<Select>` для логики навигации.
- **Прокрутка:** `Select` не принимает параметр `ViewportRows` явно — он сам управляет отображением. Для явного ограничения viewport нужен `<Scrollable<T>>` или внешнее ограничение списка `Options`.
#### `<Scrollable<T>>`
- Управляет пагинацией и клавишной навигацией (стрелки, PageUp/Down, Home/End) самостоятельно.
- `ChildContent` получает `ScrollContext<T>` с видимыми элементами и (по документации) keyboard event handlers. Конкретные названия handler-ов в XML-документации не описаны — нужна инспекция DLL или исходники.
- `PageSize` — количество видимых элементов за раз (по умолчанию 1).
#### `<TextInput>`
- `OnInput` — срабатывает на **каждый** символ. Подходит для захвата произвольных нажатий если нужен «ввод текста».
- `OnSubmit` — Enter.
- Не использовать как скрытый «перехватчик» клавиш: он не перехватывает служебные клавиши (Tab, стрелки).
#### `<TextButton>`
- `OnClick` — активируется Enter или пробелом когда кнопка в фокусе.
- Нет события `OnFocus` / `IsFocusedChanged` — нельзя реагировать на момент получения фокуса через Tab.
### FocusManager
- **Не использовать `FocusNextAsync()` в `OnAfterRenderAsync` без условия** — вызов после каждого рендера перемещает фокус по кругу, сбивая пользовательскую навигацию.
- **Tab глобально обрабатывается FocusManager** — `FocusNextAsync()` вызывается фреймворком автоматически. Повторный вызов в коде → двойное смещение фокуса.
- **`FocusManager.CurrentFocusKey`** — ключ текущего элемента в фокусе. Можно использовать для восстановления фокуса: сохранить ключ, после рендера вызвать `FocusAsync(savedKey)`.
- **Если в интерфейсе нет ни одного фокусируемого компонента** (`Select`, `TextInput`, `TextButton`) — FocusManager не читает клавиши. Это позволяет безопасно взять чтение консоли на себя.
### Ввод с клавиатуры (правила)
1. **Один читатель консоли.** `Console.ReadKey` не мультиплексируется — если FocusManager читает клавиши (есть хотя бы один фокусируемый компонент), свой `ReadKey`-поток конкурирует с ним и теряет нажатия.
2. **Забрать ввод целиком** можно убрав все фокусируемые компоненты и запустив собственный поток с `Console.ReadKey(intercept: true)`. Тогда FocusManager не активирует свой reader.
3. **Polling `Console.KeyAvailable` — антипаттерн.** Под Windows захватывает консольный mutex при каждой проверке. При частом опросе (< 50ms) конкурирует с рендером и вызывает заметные тормоза. Используй блокирующий `Console.ReadKey` в выделенном потоке.
4. **Нажатие из фонового потока → `InvokeAsync`.** После чтения клавиши и изменения стейта компонента всегда оборачивай в `InvokeAsync` перед `StateHasChanged`.
### Регистрация в DI
```csharp
// Паттерн: сервис доступен и как синглтон для инжекции, и как IHostedService для запуска
services.AddSingleton<MyService>();
services.AddHostedService(sp => sp.GetRequiredService<MyService>());
```
Альтернатива `AddHostedService<T>()` регистрирует **transient**-экземпляр, который нельзя инжектить — для TUI-сервисов всегда использовать паттерн выше.
### Отладка
- **Логи не видны в терминале во время работы TUI** — RazorConsole владеет stdout. Все `.NET`-логи надо направлять в `InMemoryLogSink` и отображать во вкладке Logs.
- **Для инспекции публичного API пакета** без исходников: создать временный `net10.0`-проект, загрузить DLL через `Assembly.LoadFrom` и перечислить типы/методы рефлексией. XML-документация пакета (`.xml` рядом с `.dll` в NuGet-кеше) — первая точка поиска.
- **`Console.IsInputRedirected`** — проверять перед любым обращением к `Console.ReadKey` / `Console.KeyAvailable`. В CI или при перенаправлении stdin эти вызовы бросают исключение.

View File

@@ -0,0 +1,148 @@
# Active Context — Текущее состояние работы
## 📍 Где мы находимся
### Состояние проекта
**LazyBear MCP Server** находится на этапе **Development**. Сервер предоставляет MCP инструменты для работы с:
-**Jira** — Issues, JQL, комментарии
-**Confluence** — Страницы, пространства
-**Kubernetes** — Deployments, Pods, Services, Ingress
-**TUI** — Интерактивная консольная панель
### Последнее обновление кода
**Commit**: `d12e9873f0964f2c275a634cda80b161c83f9bbb`
### Текущий фокус
**Активная разработка**: Инициализация Memory Bank для поддержания документации между сессиями.
**Следующие шаги**:
1. ✅ Создать Memory Bank структуру
2. 🔄 Продолжить документирование архитектуры и паттернов
3. ⏳ Обновлять Memory Bank после значимых изменений
4. ⏳ Добавить Qdrant в Memory Bank
## 🔍 Важные решения
### Архитектурные паттерны
| Паттерн | Описание | Где используется |
|---------|----------|------------------|
| **Single HTTP Server** | Один транспорт MCP | Program.cs — один AddMcpServer |
| **Tool Registry** | Регистрация модулей | ToolRegistryService.RegisterModule |
| **Client Provider** | Factory для клиентов | K8sClientProvider, JiraClientProvider |
| **TUI First** | TUI как основной UI | RazorConsole<App> в Program.cs |
### Технические решения
- **MCP Protocol 1.2.0** — HTTP transport
- **.NET 10 SDK** — Пин в global.json
- **ASP.NET Core 9** — HTTP server
- **RazorConsole** — TUI framework
- **Kubernetes Client** — Через kubeconfig
### Конфигурация
**appsettings.json**:
```json
{
"Kubernetes": {
"KubeconfigPath": "", // Fallback к defaults
"DefaultNamespace": "default"
},
"Jira": {
"Url": "", // Требуется для Jira tools
"Token": "",
"Project": ""
},
"Confluence": {
"Url": "",
"Token": "",
"Username": "",
"SpaceKey": ""
},
"Qdrant": {
"Url": "", // URL Qdrant сервера
"ApiKey": "", // Опционально для авторизации
"DefaultCollection": "knowledge" // Default коллекция для векторного поиска
}
}
```
## 🚧 Текущая работа
### Что работает
- ✅ MCP HTTP transport активен
- ✅ TUI запуск через RazorConsole
- ✅ Инструменты Jira/Confluence/Kubernetes зарегистрированы
- ✅ Локализация RU/EN (клавиша L)
### Что в разработке/тестировании
- 🔄 Memory Bank инициализация
- ⏳ Тестирование через MCP Inspector
- ⏳ Интеграция с IDE (Codex, VS Code)
### Известные проблемы
- Jira требует настройки `Jira:Url`
- Kubernetes: пустой `KubeconfigPath` использует fallback
- Razor Pages в `Pages/` не активирован (не используется)
## 📋 Следующие действия
### Приоритет 1: Конфигурация
```bash
# Настроить Jira
Jira:Url=https://your-jira.url
Jira:Token=your-token
Jira:Project=LAZYBEAR
# Настроить K8s
Kubernetes:KubeconfigPath=~/.kube/config
```
### Приоритет 2: Тестирование
```bash
# Запустить сервер
dotnet run --project LazyBear.MCP
# Открыть MCP Inspector
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
```
### Приоритет 3: Документация
- 📝 Обновлять activeContext.md после значимых изменений
- 📝 Добавлять секции в systemPatterns.md при введении новых паттернов
## 💡 Выводы и наблюдения
### Изученное о проекте
1. **Проект уже полностью функционален** — все модули готовы
2. **TUI First подход** — основной UI консольный, HTTP в фоне
3. **Minimal API Style** — чистый код, DI через сервисы
4. **Локализация встроена** — RU/EN переключение через клавишу
### Предпочтения проекта
- **Комментарии и сообщения**: Русский язык
- **Код и переменные**: Английский язык
- **Style**: Clean code, minimal abstractions
- **Documentation**: Markdown в memory-bank/
### Важные уроки
- MCP tools авт. регистрируются из assembly через `WithToolsFromAssembly()`
- IToolModule интерфейсы — способ добавлять новые инструменты
- RazorConsole управляет консолью, HTTP host работает в фоне
---
*Файл автоматически обновляется при значимых изменениях проекта. Читать при начале новой сессии.*

View File

@@ -0,0 +1,214 @@
# Implementation Plan: GitLab MCP Tools
## Overview
Создать модуль GitLab для MCP сервера с инструментами управления репозиториями, тегов (версий), Merge Requests, Issues и ветками.
## Types
### GitLabToolsBase
Базовый класс для всех инструментов GitLab:
```csharp
public sealed class GitLabToolsBase(
GitLabClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<GitLabToolsBase>? logger = null)
: IToolModule
{
protected readonly GitLabApiClient _client;
protected readonly string _baseUrl;
protected readonly int _perPageDefault;
// Валидация и форматирование ошибок
protected bool TryCheckEnabled(string toolName, out string error);
protected bool TryGetClient(out GitLabApiClient client, out string error);
protected string FormatError(string toolName, string resource, Exception ex);
}
```
### GitLabRepositoryTools
Работа с репозиториями:
```csharp
public sealed class GitLabRepositoryTools : GitLabToolsBase
{
// Инструменты:
// - ListRepositories: список доступных репозиториев (user)
// - GetRepository: получение деталей репозитория
// - GetRepositoryLanguages: языки проекта
// - GetCommit: получение коммита
// - GetCommitHistory: история коммитов
// - GetFiles: получение файлов из репозитория
// - SearchRepositories: поиск репозиториев
}
```
### GitLabVersionTools
Управление тегами (версиями):
```csharp
public sealed class GitLabVersionTools : GitLabToolsBase
{
// Инструменты:
// - ListTags: список всех тегов проекта
// - CreateTag: создание нового тега
// - DeleteTag: удаление тега
// - GetTag: получение информации о теге
// - GetTagCommit: получение коммита тега
// - ListProtectedTags: список защищённых тегов
}
```
### GitLabMergeRequestTools
Работа с Merge Requests и замечаниями:
```csharp
public sealed class GitLabMergeRequestTools : GitLabToolsBase
{
// Инструменты:
// - ListMergeRequests: список MR в проекте
// - GetMergeRequest: получение деталей MR
// - CreateMergeRequest: создание MR
// - UpdateMergeRequest: обновление MR
// - CloseMergeRequest: закрытие MR
// - GetMergeRequestComments: чтение замечаний MR
// - AddMergeRequestComment: добавление замечания
// - UpdateMergeRequestComment: обновление замечания
// - DeleteMergeRequestComment: удаление замечания
// - GetMergeRequestStatus: статус MR (merged, closed, etc.)
// - GetMergeRequestDiff: diff MR
// - GetMergeRequestPipeline: пайплайн MR
}
```
### GitLabIssueTools
Работа с Issues:
```csharp
public sealed class GitLabIssueTools : GitLabToolsBase
{
// Инструменты:
// - ListIssues: список Issues проекта
// - GetIssue: получение Issue
// - CreateIssue: создание Issue
// - UpdateIssue: обновление Issue
// - CloseIssue: закрытие Issue
// - DeleteIssue: удаление Issue
// - GetIssueLabels: метки Issue
// - AddIssueLabel: добавление метки
// - RemoveIssueLabel: удаление метки
// - GetIssueAssignees: assignees Issue
// - GetIssueEvents: события Issue
}
```
### GitLabBranchTools
Работа с ветками:
```csharp
public sealed class GitLabBranchTools : GitLabToolsBase
{
// Инструменты:
// - ListBranches: список веток
// - CreateBranch: создание ветки
// - DeleteBranch: удаление ветки
// - ProtectBranch: защита ветки
// - UnprotectBranch: снятие защиты
// - GetBranch: информация о ветке
// - GetBranchCommit: последний коммит ветки
}
```
## Files
### Новые файлы для создания:
1. `LazyBear.MCP/Services/GitLab/GitLabToolModule.cs` - регистрация модуля
2. `LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs` - provider клиента
3. `LazyBear.MCP/Services/GitLab/GitLabApiClientFactory.cs` - factory клиента
4. `LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs` - работа с репозиториями
5. `LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs` - работа с тегами
6. `LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs` - работа с MR
7. `LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs` - работа с Issues
8. `LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs` - работа с ветками
9. `LazyBear.MCP/Services/GitLab/Utils/GitLabClientHelper.cs` - вспомогательные методы
### Модифицируемые файлы:
1. `LazyBear.MCP/appsettings.json` - добавить секцию GitLab
2. `LazyBear.MCP/Program.cs` - регистрация GitLab модуля
3. `LazyBear.MCP/TUI/TuiSettings.cs` - добавить настройки GitLab
## Functions
### Основные функции:
1. `GitLabRepositoryTools.ListRepositories()` - список всех проектов
2. `GitLabRepositoryTools.GetRepository(string projectId)` - детали проекта
3. `GitLabVersionTools.ListTags(string projectId)` - список тегов
4. `GitLabVersionTools.CreateTag(string projectId, string tagName, string commitSha)` - создание тега
5. `GitLabMergeRequestTools.CreateMergeRequest(string sourceBranch, string targetBranch, string title, string description)` - создание MR
6. `GitLabMergeRequestTools.AddMergeRequestComment(string projectId, int mergeRequestId, string comment)` - добавление замечания
7. `GitLabIssueTools.ListIssues(string projectId)` - список Issues
## Classes
Новые классы в `LazyBear.MCP/Services/GitLab/`:
- `GitLabToolsBase` - базовый класс с common-методами
- `GitLabClientProvider` - provider для клиента
- `GitLabApiClientFactory` - factory для создания клиентов
- `GitLabRepositoryTools` - инструменты репозиториев
- `GitLabVersionTools` - инструменты тегов
- `GitLabMergeRequestTools` - инструменты MR
- `GitLabIssueTools` - инструменты Issues
- `GitLabBranchTools` - инструменты веток
## Dependencies
### NuGet пакеты:
1. **Octokit.AspNetCore** 7.1.0 (или более поздняя версия) - GitLab REST API client
2. **System.Text.Json** - уже есть в .NET SDK
### Примечание:
Использовать Octokit для GitLab - это не стандартный подход. Octokit ориентирован на GitHub API.
**Альтернативный подход:**
Использовать **GitLabDotnet** или **RestSharp** с официальным GitLab API.
Рекомендуется использовать **RestSharp** (уже используется в Jira модуле) или официальный **GitLab SDK** (если доступен).
Пакет: **RestSharp** 111.1.0 (уже есть в проекте через Jira)
Или использовать библиотеку: **GitLabApi** (от Octokit) - https://github.com/octokit/octokit.net/wiki/Using-the-GitLab-API
В проекте уже используется RestSharp через Jira модуль. GitLab API использует REST, поэтому RestSharp подходит.
## Testing
### Тесты:
- Валидация входных параметров
- Проверка ошибок GitLab API
- Тесты с mock-клиентом
- Проверка формата ответов
Примечание: тестовый проект пока не создаём, используем existing tests if available.
## Implementation Order
1. Создать структуру папок GitLab
2. Реализовать GitLabToolsBase с common-методами
3. Реализовать GitLabClientProvider и GitLabApiClientFactory
4. Реализовать GitLabRepositoryTools
5. Реализовать GitLabVersionTools
6. Реализовать GitLabMergeRequestTools (включая MR comments)
7. Реализовать GitLabIssueTools
8. Реализовать GitLabBranchTools
9. Обновить Program.cs для регистрации модуля
10. Обновить appsettings.json
11. Тестирование и отладка

View File

@@ -0,0 +1,183 @@
# Product Context — Зачем существует LazyBear MCP Server
## Проблема, которую решает проект
### Контекст пользователя
DevOps инженер и разработчики ежедневно работают с:
- **Jira** — создание, отслеживание, обновление задач
- **Confluence** — документация, руководства, знания
- **Kubernetes** — управление контейнерами, деплоями, сетями
### Точка боли
**Ручная работа**: Каждый инструмент требует отдельного CLI вызова:
```bash
# Jira CLI
jira issue create --project LAZYBEAR ...
# Confluence CLI
confluence create --space LAZYBEAR ...
# Kubernetes
kubectl create deployment nginx --image=nginx:latest
kubectl scale deployment nginx --replicas=3
```
**Неэффективно**: Много времени тратится на переключение контекста, ввод команд, копирование значений.
**Нет единой точки управления**: Разрозненные инструменты, неясно что активно и где.
### Решение
**LazyBear MCP Server** объединяет все инструменты в один HTTP сервер:
- Один вызов от IDE вызывает нужное действие
- JSON-параметры вместо сложных CLI флага
- Визуальный мониторинг через TUI
- Локализация на русском языке
### Пользовательский опыт
**До интеграции**:
- 5-10 CLI команд для рутинных задач
- Переключение между терминалами и браузерами
- Потеря контекста между инструментами
**После интеграции**:
- 1 JSON вызов от IDE решает задачу
- Единая консоль TUI показывает состояние кластера
- Русская локализация для команды RU
- Свободный фокус на логике работы
## Целевой пользователь
### Persona: DevOps Engineer
**Характеристики**:
- Работает с Kubernetes кластерами
- Создает и обновляет Jira задачи
- Документирует процессы в Confluence
- Ценит автоматизацию
**Цели**:
- Минимизировать ручной ввод
- Быстро реагировать на инциденты
- Поддерживать актуальную документацию
**Боли**:
- Утомительный ручной ввод
- Потеря контекста между инструментами
- Недостаток visibility в работе инструментов
### Persona: Developer
**Характеристики**:
- Работает в IDE (Codex, VS Code)
- Использует AI-ассистенты
- Ценит быструю обратную связь
**Цели**:
- Автоматизировать рутину через AI
- Быстрый доступ к информации о сервисах
**Боли**:
- AI не знает состояние K8s/Jira без интеграции
- Потеря времени на setup команд
## Успешные сценарии использования
### Сценарий 1: Инцидент в Kubernetes
```
User (via AI): "В поде nginx-abc123 упал. Попробуй рестарт."
AI вызывает: k8sPodsTools/restartPod(name="nginx-abc123")
LazyBear вызывает K8s API
Получает результат
AI сообщает: "Под nginx-abc123 перезапущен"
```
### Сценарий 2: Создание задачи инцидента
```
User (via AI): "Создать Jira задачу на проблему с nginx"
AI вызывает: jiraTools/createIssue({
project: "LAZYBEAR",
summary: "Нестабильность nginx",
description: "...",
type: "INCIDENT"
})
Jira задача создана
```
### Сценарий 3: Мониторинг через TUI
```
User запускает dotnet run
TUI показывает:
- Список deployed деплоев
- Статус подов
- Последние events
- Кнопки действий
- Векторные индексы Qdrant (если используется)
```
### Сценарий 4: Поиск знаний через Qdrant
```
User (via AI): "Как настроить деплой nginx?"
AI вызывает: qdrantKnowledgeTools/search({
query: "nginx деплой настройка",
vector_embedding: [0.1, 0.2, ...]
})
Qdrant возвращает релевантные документы
AI предоставляет из Confluence/документации
```
## Метрики успеха
| Метрика | Цель |
|---------|------|
| Время на задачу (Jira) | -50% после интеграции |
| Время на задачу (K8s) | -70% после интеграции |
| Время на поиск знаний | -60% с Qdrant |
| Довольство пользователей | >4.5/5 |
| Количество инцидентов | Снизить на -30% |
## Нестандартные требования
### Локализация
- **Первичный язык**: Русский (RU)
- **Вторичный язык**: Английский (EN)
- **Переключение**: Клавиша L в TUI
### TUI First
- Основной интерфейс — консольная панель
- HTTP MCP работает в фоне
- TUI владелец консоли
### Swagger для API
- `/swagger` для документации API
- Для разработчиков клиентов
## Этические соображения
- **Конфиденциальность**: Контенджер не получает реальные секреты
- **Transparency**: Все действия логируются
- **Fallback**: K8s config fallback к defaults
---
*Документ описывает почему проект существует и как должен работать пользователь.*

191
memory-bank/progress.md Normal file
View File

@@ -0,0 +1,191 @@
# Progress — Состояние разработки
## 📊 Что работает
### Jira Integration
- ✅ Создание Issues через JQL
- ✅ Поиск Issues по фильтру
- ✅ Обновление Issues (статус, приоритет)
- ✅ Работа с комментариями (добавление, получение)
- ✅ Создание ссылок Issue
### Confluence Integration
- ✅ CRUD операции над страницами
- ✅ Поиск страниц
- ✅ Перемещение страниц между пространствами
- ✅ Создание/ред. страниц
### Kubernetes Integration
- ✅ Создание/удаление Deployments
- ✅ Scale deployments (replicas)
- ✅ Работа с Services
- ✅ CRUD над Ingress ресурсами
- ✅ Мониторинг Pods
- ✅ Restart Pods
- ✅ Описание Deployments/Pods/Services
### GitLab Integration
- ✅ Работа с репозиториями
- `list_projects` — получить список репозиториев
- `get_project` — получить репозиторий по ID
- ✅ Управление тегами
- `list_versions` — получить список тегов
- `create_version` — создать тег
- `delete_version` — удалить тег
- ✅ Работа с Merge Requests
- `list_merge_requests` — получить список MR
- `get_merge_request` — получить MR по ID
- `get_merge_request_notes` — получить замечания
- `add_merge_request_note` — добавить замечание
- ✅ Работа с Issues
- `list_issues` — получить список Issues
- `get_issue` — получить Issue по ID
- `create_issue` — создать Issue
- ✅ Работа с ветками
- `list_branches` — получить список веток
- `create_branch` — создать ветку
- `delete_branch` — удалить ветку
### Qdrant Integration (Vector DB)
- ✅ CRUD коллекции (создание, удаление)
- ✅ Добавление/обновление документов (upsert)
- ✅ Векторный поиск по коллекции
- ✅ Удаление документов по ID
- ✅ Поддержка метрик (Cosine, Euclid, Dot)
- ✅ Поддержка API ключа (опционально)
### MCP Server
- ✅ HTTP Transport MCP 1.2.0
- ✅ TUI через RazorConsole
- ✅ Авто-регистрация инструментов
- ✅ Регистрация через IToolModule
### TUI Dashboard
- ✅ Интерактивная консольная панель
- ✅ Мониторинг Deployments
- ✅ Статус Pods
- ✅ Последние K8s events
- ✅ Кнопки действий
### Локализация
- ✅ Поддержка RU/EN языков
- ✅ Переключение клавишей L
## 📋 Что осталось сделать
### Known Issues
| Проблема | Описание | Приоритет |
|-----|------|-|
| Jira:Url config | Требуется настройка в appsettings.json | Medium |
| Kubernetes:KubeconfigPath | Может быть пустым (fallback) | Low |
| RazorPages не активен | `Pages/` существует, но не используется | Info |
### Pending Features
- [ ] Добавить тестирование (unit/integration)
- [ ] Document all tools в Swagger
- [ ] Добавить webhook для событий K8s
- [ ] Добавить AI-ассистента для анализа логов
- [ ] Добавить экспорт логов в файлы
### Upcoming Tasks
1. **Конфигурация**: Настроить Jira/K8s/GitLab connection в appsettings.json
2. **Тестирование**: Написать CLI тесты для инструментов
3. **Документация**: Добавить секции в systemPatterns.md при необходимости
4. **Monitoring**: Добавить metrics endpoint для Prometheus
## 🐛 Известные проблемы
### Jira Integration
- `Jira:Url` обязателен в appsettings.json, иначе инициализация провайдера упадет
- `Jira:Token` — требуется авторизация через token
- `Jira:Project` — для создания Issues нужно указать project key
### Kubernetes Integration
- `Kubernetes:KubeconfigPath` может быть пустым:
1. Используется default kubeconfig
2. Или в-cluster config (если в K8s)
- `Kubernetes:DefaultNamespace` — default namespace для запросов
### Confluence Integration
- `Confluence:Url` — URL Confluence сервера
- `Confluence:Token` — API token для авторизации
- `Confluence:SpaceKey` — для некоторых операций требуется space
### GitLab Integration
- `GitLab:Url` — обязателен в appsettings.json
- `GitLab:Token` — PAT token для авторизации
### TUI
- TUI запускается первым и владеет консолью
- HTTP host работает параллельно
- Если пользователь закрывает TUI, HTTP продолжает работать
### Localization
- Переключение языков клавишей L в TUI
- UI компоненты поддерживают оба языка
- Тексты берутся из `LocalizationService`
## 📈 Метрики
| Метрика | Значение | Цель |
|-----|------|-|
| Jira задачи создано | TBD | 100+/день |
| K8s операции выполнено | TBD | 50+/день |
| GitLab операции выполнено | TBD | 50+/день |
| Incidents resolved | TBD | Минимизировать |
| User satisfaction | TBD | >4.5/5 |
## 📅 Эволюция решений
### Версия 1.0.0 (текущая)
- Полный стек инструментов Jira/Confluence/K8s/GitLab
- TUI мониторинг
- HTTP MCP transport
- Локализация RU/EN
### Версия 1.1.0 (planned)
- Тесты для инструментов
- Prometheus metrics
- Webhook события
### Версия 1.2.0 (future)
- AI-ассистент для анализа логов
- Export reports в PDF/CSV
- Multi-cluster поддержка
## ⏩ Текущий статус
**Состояние**: Development
**Последний commit**: `b5fe2623b3d14333a7138c22456862bff3781b82`
**Что работает**: Все основные функциональности готовы
**Что делать дальше**:
1. Настроить Jira/K8s/GitLab connection (appsettings.json)
2. Тестировать через MCP Inspector
3. Обновлять Memory Bank при значимых изменениях
---
*Файл описывает что работает, что осталось, известные проблемы и прогресс разработки. Обновлять после значимых изменений проекта.*

View File

@@ -0,0 +1,63 @@
# Project Brief — LazyBear MCP Server
## Обзор проекта
**LazyBear MCP Server** — .NET 10 сервер Model Context Protocol (MCP) для автоматизации работы с Jira, Confluence и Kubernetes.
## Цель проекта
Предоставить единый HTTP-сервер MCP, который:
- Интегрируется с IDE (Codex, VS Code, OpenCode) через MCP протокол
- Предоставляет инструменты для управления Jira задачами (JQL, комментарии, обновления)
- Работает с Confluence страницами и пространствами
- Управляет Kubernetes ресурсами (deployments, pods, services, ingress)
- Предоставляет интерактивную TUI панель для мониторинга кластера
## Ключевые требования
### Функциональные требования
1. **MCP Protocol 1.2.0** — HTTP transport по стандарту Model Context Protocol
2. **Jira Integration** — Создание, поиск, обновление Issues, работа с комментариями
3. **Confluence Integration** — Управление страницами, поиск, перемещение
4. **Kubernetes Management** — CRUD операции над deployments, pods, services, ingress
5. **TUI Dashboard** — Интерактивная консольная панель с RazorConsole
6. **Localization** — Поддержка RU/EN языков в интерфейсе
### Технические требования
- **.NET 10 SDK** — SDK зафиксирован в global.json
- **ASP.NET Core 9** — Для HTTP transport
- **Razor Pages** — UI компоненты (активируется при необходимости)
- **Kubernetes Client** — Подключение к K8s API через kubeconfig
- **SQLite/SQL Server** — Опциональная база данных
- **Swagger UI** — `/swagger` endpoint для просмотра API
## Основные ограничения
- Jira: требуется настроить `Jira:Url` в appsettings.json
- Kubernetes: `Kubernetes:KubeconfigPath` может быть пустым (используются fallback)
- Конфиденциальность: никогда не хранить реальные секреты, токены, kubeconfig в коде
## Архитектурные принципы
1. **Single HTTP Server** — Один транспорт MCP для всех инструментов
2. **Tool Registry Pattern** — Регистрация модулей инструментов через IToolModule
3. **Client Provider Pattern** — Factory для создания K8s/Jira/Confluence клиентов
4. **TUI First** — TUI как основной интерфейс, HTTP MCP в фоне
5. **Minimal API Style** — Clean code, разделение ответственности
## Даты и версии
- **Текущая версия**: 1.0.0
- **Состояние**: Development
- **Комит**: `d12e9873f0964f2c275a634cda80b161c83f9bbb`
## Репо (Git)
- **Remote**: `https://git.shahovalov.ru/mikhail/LazyBearWorks.git`
- **Branch**: main (предположительно)
---
*Документ создан для инициализации Memory Bank. Источник правды: код и конфигурация проекта.*

View File

@@ -0,0 +1,322 @@
# System Patterns — Архитектура и паттерны
## 🏗️ Система архитектуры
```
┌─────────────────────────────────────────────────────────┐
│ HTTP Transport Layer │
│ ModelContextProtocol 1.2.0 HTTP │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Application Layer (TUI + MCP) │
│ ┌───────────────┐ ┌───────────────┐ ┌──────────────┐│
│ │ RazorConsole │ │ McpWeb │ │ ToolRegistry││
│ │ App.razor │ │ Hosted │ │ Service ││
│ └───────────────┘ └───────────────┘ └──────────────┘│
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Services Layer (IToolModule) │
│ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐ ┌─────┐│
│ │JiraTools │ │ConfluenceTools│ │KubernetesTools │ │Qdrant││
│ └──────────┘ └───────────────┘ └────────────────────┘ └─────┘│
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ External API Layer │
│ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐ ┌────┐│
│ │ Jira API │ │Confluence API │ │ K8s API │ │Qdr│ │
│ └──────────┘ └───────────────┘ └────────────────────┘ └───┘ │
└─────────────────────────────────────────────────────────┘
```
## Ключевые архитектурные компоненты
### 1. HTTP Transport Layer
**File**: `LazyBear.MCP/Program.cs`
```csharp
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
// MCP-провайдеры
services.AddSingleton<K8sClientProvider>();
services.AddSingleton<JiraClientProvider>();
services.AddSingleton<ConfluenceClientProvider>();
// Модули инструментов
services.AddSingleton<IToolModule, JiraToolModule>();
services.AddSingleton<IToolModule, KubernetesToolModule>();
services.AddSingleton<IToolModule, ConfluenceToolModule>();
// HTTP MCP endpoint в фоне
services.AddHostedService<McpWebHostedService>();
})
.Build();
```
**Ответственность**: Регистрация HTTP MCP transport и инструментов
### 2. TUI Layer (RazorConsole)
**File**: `LazyBear.MCP/Program.cs` (RazorConsole конфиг)
```csharp
.host.UseRazorConsole<App>(hostBuilder =>
{
hostBuilder.ConfigureServices(services =>
{
services.AddSingleton<TUI.Components.GlobalKeyboardService>();
services.AddSingleton<LocalizationService>();
// ... TUI сервисы
});
});
```
**Ответственность**: Консольный интерфейс мониторинга K8s
### 3. Tool Registry Pattern
**File**: `LazyBear.MCP/Services/ToolRegistryService.cs` (предположительно)
```csharp
public class ToolRegistryService
{
public void RegisterModule(IToolModule module)
{
// Регистрация модуля инструментов
// Модуль появляется в TUI и MCP tools
}
}
```
**Ответственность**: Централизованная регистрация инструментов
**Register в Program.cs**:
```csharp
var registry = host.Services.GetRequiredService<ToolRegistryService>();
foreach (var module in host.Services.GetServices<IToolModule>())
{
registry.RegisterModule(module);
}
```
### 4. Client Provider Pattern
**Files**:
- `LazyBear.MCP/Services/Kubernetes/K8sClientProvider.cs`
- `LazyBear.MCP/Services/Jira/JiraClientProvider.cs`
- `LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs`
```csharp
public class K8sClientProvider
{
private readonly IConfiguration _config;
public KubernetesClientFactory GetClientFactory()
{
// Fallback: explicit kubeconfig → default → in-cluster
var kubeconfigPath = _config["Kubernetes:KubeconfigPath"]
?? Environment.GetEnvironmentVariable("KUBECONFIG")
?? string.Empty;
return new KubernetesClientFactory(kubeconfigPath);
}
}
```
**Ответственность**: Создание клиентов внешних API с fallback стратегией
## Паттерны взаимодействия
### Pattern 1: Initialize Flow
```
Клиент MCP
↓ [RPC: initialize]
MCP Transport
Application (ToolRegistry)
Tool Module (Jira/K8s/Confluence)
External API (Jira API / K8s API)
Ответ через MCP transport
```
### Pattern 2: Tools Flow
```
Клиент MCP
↓ [RPC: tool/name]
ToolRegistry.FindTool()
ToolModule.Execute()
External API
Результат → MCP response
```
### Pattern 3: Health Check Flow
```
HTTP /health
McpWebHostedService
Лiveness probe статус
JSON response
```
## DI Container Setup
```csharp
services.AddSingleton<...>(...)
HostedService (McpWeb)
RazorConsole (TUI)
RunAsync()
```
**Примечание**: TUI запускается первым и владеет консолью, HTTP host работает параллельно в фоне.
## Критические пути реализации
### Путь 1: Создание Jira задачи
```
jiraTools/createIssue(params)
JiraToolModule.Execute()
JiraClientProvider.GetClient()
Jira API REST
Ответ с issue key
```
### Путь 2: Создание K8s Deployment
```
k8sDeploymentTools/createDeployment(params)
KubernetesToolModule.Execute()
K8sClientProvider.GetClientFactory().CreateClient()
K8s API V1/Deployments
Ответ с deployment name
```
### Путь 3: Мониторинг через TUI
```
RazorConsole Render()
GetDeployments() → GetPods() → GetEvents()
App.razor (RazorConsole)
Console.Output
```
## Компонентные контракты
### IToolModule
```csharp
public interface IToolModule
{
string Name { get; }
Task<object> ExecuteAsync(IToolContext context, string toolName, object? arguments);
}
```
**Ответственность**: Определение одного модуля инструментов (Jira/Confluence/K8s)
### IToolContext
Контекст выполнения инструмента:
- MCP connection info
- Tool registry
- DI services
- Logging provider
### K8sClientFactory
```csharp
public class KubernetesClientFactory
{
public KubernetesClient CreateClient(string? kubeconfigPath);
}
```
**Fallback порядок**:
1. Explicit kubeconfig path
2. Environment variable KUBECONFIG
3. In-cluster config (если внутри кластера)
## Logging Architecture
```
InMemoryLoggerProvider
InMemoryLogSink
Console.WriteLine
```
**Конфиг**:
```json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"ModelContextProtocol": "Debug"
}
}
}
```
---
### Qdrant Client Provider Pattern
**File**: `LazyBear.MCP/Services/Qdrant/QdrantClientProvider.cs`
```csharp
public class QdrantClientProvider
{
private readonly IConfiguration _config;
public QdrantClient GetClient()
{
// Конфиг из appsettings.json
var url = _config["Qdrant:Url"];
var apiKey = _config["Qdrant:ApiKey"] ?? string.Empty;
return new QdrantClient(url, apiKey);
}
}
```
**Fallback порядок**:
1. Explicit URL из конфига
2. Environment variable QDRANT_URL
3. Localhost default
**Ответственность**: Создание клиентов Qdrant с поддержкой API ключа (опционально)
---
*Файл описывает систему архитектуры, ключевые компоненты и потоки данных. Обновлять при введении новых архитектурных решений.*

283
memory-bank/techContext.md Normal file
View File

@@ -0,0 +1,283 @@
# Tech Context — Технологии и разработка
## 🛠️ Используемые технологии
### Основные технологии
| Технология | Версия | Назначение |
|------------|--------|-------------|
| **C#** | .NET 10 | Язык программирования |
| **.NET SDK** | 10 | Runtime и SDK |
| **ASP.NET Core** | 9 | HTTP server, MCP transport |
| **Razor Pages** | 9+ | TUI компоненты (RazorConsole) |
| **Model Context Protocol** | 1.2.0 | MCP стандарт |
| **Kubernetes Client** | 13+ | .NET SDK для K8s |
| **RazorConsole** | Latest | TUI framework |
| **Qdrant.Client** | Latest | Векторный поиск,知识库 |
### Файлы конфигурации
**Global SDK pin**:
- `LazyBear.MCP/global.json` — пин версий .NET SDK
**Main config**:
- `LazyBear.MCP/appsettings.json` — runtime конфиг (K8s, Jira, Confluence)
- `LazyBear.MCP/appsettings.Development.json` — development overrides
**Launch settings**:
- `LazyBear.MCP/Properties/launchSettings.json` — (но trust Program.cs для портов)
### External Dependencies
**NuGet packages** (предположительно):
- `ModelContextProtocol` — MCP protocol
- `ModelContextProtocol.AspNetCore` — HTTP transport
- `KubernetesClient` — K8s .NET client
- `RazorConsole` — TUI framework
- `Swashbuckle.AspNetCore` — Swagger UI
## 🖥️ Development Setup
### Требования
```bash
# .NET SDK 10
dotnet --version # должно быть >= 10
# Kubectl (для K8s)
kubectl version --client
# Docker Desktop (опционально)
docker --version
```
### Установка
```bash
# Clone repo
git clone https://git.shahovalov.ru/mikhail/LazyBearWorks.git
cd LazyBearWorks
# Build
dotnet build
# Run
dotnet run --project LazyBear.MCP
```
### Запуск
**Terminal запуска**:
```bash
# Консольный режим (TUI)
dotnet run --project LazyBear.MCP
# С MCP Inspector
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
# С Swagger
# Включить в Program.cs: AddSwaggerGen()
dotnet run --project LazyBear.MCP
```
**Runtime URL**: `http://localhost:5000`
**Примечание**: `Properties/launchSettings.json` показывает другой порт — trust Program.cs.
## 📦 Проектная структура
```
LazyBear.MCP/
├── Program.cs # Entry point, DI setup
├── appsettings.json # Config (K8s, Jira, Confluence)
├── global.json # SDK pin
├── Properties/
│ └── launchSettings.json # (override: trust Program.cs)
├── Pages/ # Razor Pages UI (не активирован)
│ ├── Index.cshtml
│ └── Shared/
├── Services/
│ ├── Jira/
│ │ ├── JiraIssueTools.cs
│ │ └── JiraClientProvider.cs
│ ├── Confluence/
│ │ └── ConfluencePagesTools.cs
│ ├── Qdrant/
│ │ ├── QdrantClientProvider.cs
│ │ ├── QdrantKnowledgeTools.cs
│ │ └── QdrantToolModule.cs
│ └── Kubernetes/
│ ├── K8sConfigTools.cs
│ ├── K8sDeploymentTools.cs
│ ├── K8sNetworkTools.cs
│ ├── K8sPodsTools.cs
│ ├── K8sClientFactory.cs
│ └── K8sClientProvider.cs
├── TUI/
│ ├── Components/
│ │ ├── App.razor
│ │ └── GlobalKeyboardService.cs
│ └── Localization/
└── wwwroot/ # Static files
```
## 🔧 Tool Registration
**Auto-registration в Program.cs**:
```csharp
AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
```
**Ручная регистрация в DI**:
```csharp
services.AddSingleton<IToolModule, JiraToolModule>();
services.AddSingleton<IToolModule, KubernetesToolModule>();
services.AddSingleton<IToolModule, ConfluenceToolModule>();
```
**Post-registration в Program.cs**:
```csharp
var registry = host.Services.GetRequiredService<ToolRegistryService>();
foreach (var module in host.Services.GetServices<IToolModule>())
{
registry.RegisterModule(module);
}
```
## 📝 Кодовые стандарты
### Языковые предпочтения
- **Код, переменные, типы**: Английский язык
- **Комментарии, строки, UI**: Русский язык
- **Публичные API/классы**: Английский
- **Внутренние компоненты**: Русский
### Пример кода
```csharp
// ✅ Хорошо
public class K8sDeploymentTools // Англ. класс
{
/// <summary>
/// Создать новый деплой в Kubernetes кластере.
/// </summary>
public async Task<CreateDeploymentResult> CreateDeploymentAsync(
IToolContext context,
CreateDeploymentRequest request)
{
// Русский комментарии в коде
}
}
// ❌ Плохо
public class K8sDeploymentTools
{
public async Task<object> ExecuteAsync(...)
{
return Task.FromResult(new object()); // Нет документации
}
}
```
## 🧪 Тестирование
**Инструменты**:
- MCP Inspector: `npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP`
- CLI тестирование: `echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | dotnet run`
**Тест-проекты**: Нет по умолчанию (если нужны — создать)
### Пример CLI тестирования
```bash
# Прямое тестирование через stdin
echo '{"jsonrpc":"2.0","id":1,"method":"k8sPodsTools/getPodStatus","params":{"name":"nginx"},"id":1}' | dotnet run --project LazyBear.MCP
```
## 📚 Документация
**Встроенная**:
- `LazyBear.MCP/README.md` — (если существует)
- Swagger UI: `/swagger`
**External docs**:
- MCP спецификация: https://modelcontextprotocol.io
- Kubernetes client docs: https://kubernetes-client.net
- RazorConsole docs: `docs/razorconsole/`
**Project docs**:
- `memory-bank/` — Memory Bank для контекста между сессиями
- `AGENTS.md` — инструкции для AI ассистентов
- `docs/tui_log.md` — TUI session notes
- `docs/opencode/question-policy.md` — OpenCode политика
## 🐛 Известные gotchas
### Config Gotchas
- `Jira:Url` обязателен, иначе инициализация провайдера может упасть
- `Kubernetes:KubeconfigPath` может быть пустым — используется fallback
- `Qdrant:Url` обязателен для векторного поиска, если используется
- `Qdrant:ApiKey` опционален, но рекомендуется для безопасного доступа
### Qdrant Gotchas
- Косинусная метрика — default для векторного поиска
- Размер вектора должен быть фиксированным при создании коллекции
### RazorConsole Gotchas
- TUI владеет консолью, не писать напрямую в Console.Out
- `AutoClearConsole = true` — консоль очищается после каждого рендера
- `EnableTerminalResizing = true` — поддерживает перерисовку при изменении размера
### TUI Session Notes
- Сессия начинается с `Initialize` потока
- Затем TUI рендерится и запускает мониторинг
- MCP tools доступны параллельно через HTTP
### Localization Gotchas
- Переключение языков клавишей L
- Тексты берутся из `LocalizationService`
- TUI компоненты поддерживают оба языка
## 🚀 Deployment
**Docker**:
```bash
docker build -t lazybear-mcp .
docker run -p 5000:5000 \
-v $HOME/.kube:/root/.kube:ro \
lazybear-mcp
```
**Kubernetes**:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: lazybear-mcp
spec:
containers:
- name: mcp
image: lazybear-mcp:latest
ports:
- containerPort: 5000
volumeMounts:
- name: kubeconfig
mountPath: /root/.kube
readOnly: true
volumeMounts:
- name: kubeconfig
configMap:
name: kubeconfig-config
```
---
*Файл описывает технологии, setup и важные примечания о разработке. Обновлять при введении новых технологий или зависимостей.*