Compare commits

..

50 Commits

Author SHA1 Message Date
6c5a99b8a2 feat(mcp): подключена иконка приложения и обновлены ресурсы 2026-04-14 19:36:36 +03:00
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
380768b110 Настроить порт через переменную окружения ASPNETCORE_URLS 2026-04-13 17:52:13 +03:00
879becadfe feat: внедрение RazorConsole TUI с runtime-управлением MCP-инструментами
- Добавлен RazorConsole.Core для интерактивного TUI-дашборда
- ToolRegistryService: живое включение/отключение модулей и отдельных методов
- InMemoryLogSink: кольцевой буфер логов с фильтрацией по модулю
- TUI: 3 таба (Overview, Logs, Settings)
- IToolModule: generic-интерфейс для легкого добавления новых MCP-модулей
- Guard-проверка TryCheckEnabled() во всех существующих инструментах
2026-04-13 17:31:28 +03:00
c117d928b0 Added docs RazorConsole 2026-04-13 16:53:31 +03:00
e37ab09fc5 Добавление секции OpenAPI/Swagger 2026-04-13 16:15:34 +03:00
15f29e3e6d update AGENTS.md 2026-04-13 16:10:12 +03:00
e008115ced Обновление README: унификация структуры, добавление логотипа 2026-04-13 16:09:42 +03:00
8ac5ad2bac Confluence 2026-04-13 15:57:56 +03:00
2fe64d0903 Исправление ошибок сборки JiraClientFactory 2026-04-13 15:37:31 +03:00
2fc419b490 update gitignore 2026-04-13 15:27:05 +03:00
2bab8e42fa update gitignore 2026-04-13 15:26:36 +03:00
01d75adef1 Update 2026-04-13 15:16:57 +03:00
f1392045a6 Усилить использование question в OpenCode 2026-04-13 15:08:56 +03:00
aa124b98af Чистка 2026-04-13 15:05:08 +03:00
1c7368de5b update opencode.json 2026-04-13 14:55:15 +03:00
ebaad75087 Добавить small_model в opencode.json 2026-04-13 14:50:29 +03:00
0290cb4102 Сжать и уточнить AGENTS.md 2026-04-13 14:42:27 +03:00
b5eb33272a Add Kubernetes and Jira MCP tools with auto-registration 2026-04-13 14:15:00 +03:00
87fb9e8df8 Confluence init 2026-04-13 13:40:32 +03:00
c8b7395ba8 Update AGENTS 2026-04-13 13:31:28 +03:00
62f8dd49b6 update AGENTS.md 2026-04-13 13:07:02 +03:00
3828df69da docs: update README JiraTools methods, add MEMORY rules, fix config entity 2026-04-13 12:30:44 +03:00
8962c916e8 Уточнить выбор интерактивных инструментов в AGENTS.md 2026-04-13 12:18:39 +03:00
39d637f99e Update AGENTS 2026-04-13 11:46:43 +03:00
6a8d294c74 Update opencode.json 2026-04-13 11:43:25 +03:00
38f15b894e Merge branch 'main' of https://git.shahovalov.ru/mikhail/LazyBearWorks 2026-04-13 11:34:35 +03:00
e87f3ef5cb Настроки для агента 2026-04-13 11:33:27 +03:00
7a170d137d Merge pull request 'jira-mcp' (#1) from jira-mcp into main
Reviewed-on: #1
2026-04-13 11:20:10 +03:00
35daff0c4c Синхронизация main: .opencode.json → opencode.json 2026-04-13 11:17:26 +03:00
f3964075cc Добавлен MCP для Jira
Реализованы инструменты для задач, статусов и комментариев через Jira REST API. Jira-клиент зарегистрирован в сервере и вынесен в отдельные сервисы.
2026-04-13 10:44:45 +03:00
d75a08e7d7 Обнавлен AGENTS.md 2026-04-13 10:38:40 +03:00
305496eb11 Новое лого 2026-04-13 10:37:44 +03:00
bf995b162e Добавление конфигурации Opencode и сценария настройки 2026-04-13 09:53:52 +03:00
dbf1e75aa9 Удаление вспомогательных агента файлов 2026-04-13 09:35:34 +03:00
0bcf5334d8 Зафиксированы правки 2026-04-13 09:33:20 +03:00
9b9adc3efa chore(cleanup): Зафиксированы удаление TradingTools.cs, обновление README и добавление логотипа. Соответствует принципам AGENTS.md. 2026-04-12 23:42:48 +03:00
ca20a4e7d4 Добавлен модуль Kubernetes MCP с DI и диагностикой ошибок 2026-04-12 23:12:24 +03:00
cdbb2110c9 Добавлены правила ведения Memory Log в AGENTS.md и AGENT.common.md с machine-first оптимизацией 2026-04-12 22:48:50 +03:00
366f044229 Уточнены правила в AGENT*.md и AGENTS.md для устранения дублирования и противоречий.
Сделана синхронизация структуры проекта и формулировок, чтобы правила были короче, однозначнее и соответствовали текущему состоянию репозитория.
2026-04-12 22:41:19 +03:00
90 changed files with 10474 additions and 464 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.

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
.idea/.idea.LazyBearWorks/.idea/vcs.xml
.idea/.idea.LazyBearWorks/.idea/indexLayout.xml
# ASP.NET Core
bin/
obj/
*.user
*.suo
*.cache
# Visual Studio
.vs/
# Rider
.idea/
# Microsoft
PublishFiles/
temp
# Claude Code
.claude/
LazyBear.MCP/LazyBear.MCP.csproj
/artifacts

15
.idea/.idea.LazyBearWorks/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/contentModel.xml
/.idea.LazyBearWorks.iml
/projectSettingsUpdater.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -1,26 +0,0 @@
# AGENT Common Rules
Reusable baseline rules that are not tied to this repository.
## Communication (MUST)
- Answer first, then request approval if needed.
- Concise, meaningful, no filler.
- Do not end response with only procedural choice.
- In scenarios where local configuration, secrets, or notification settings may be needed, automatically check and use `.env.local` when it exists and is relevant; if it is missing but clearly required, state that once, use `.env.example` as the schema when available, and name the required keys without inventing values; never commit `.env.local` or print secret values.
Approval/clarification:
- ask once, no repetition
- binary -> yes/no
- multiple -> numbered options + brief context
- ask dependent questions sequentially, not in one message
- if one decision changes or gates the next step, ask the prerequisite decision first and wait for the answer before asking the next one
- do not combine branch selection with operational approval in the same message
Formatting:
- File links in repo docs/checklists: relative paths, `/`, spaces as `%20`.
- In assistant UI responses, use the link format required by the execution environment; include relative path text when possible.
- External links in assistant UI responses: use markdown links with a clear label, not bare URLs.
## Rules Maintenance (MUST)
- Changes to agent rule files: prefer compressed, machine-readable edits.
- Keep updates minimal and non-duplicative: merge overlapping points, remove redundancy, preserve intent.

View File

@@ -1,233 +0,0 @@
# AGENT.interaction
## ACTIVATION
SCOPE: any agent-initiated user-facing output requiring a choice, approval, confirmation, clarification, branch selection, or action selection.
TRIGGER: automatic — no explicit activation required.
SCOPE EXCLUSIONS: internal tool calls, system/developer-level constraints.
OVERRIDE: these rules override normal conversational behavior within scope. Safety and ethics constraints take absolute precedence.
---
## OUTPUT CONTRACT
### Format
```
<INSTRUCTION>
1) <OPTION>
2) <OPTION>
3) <OPTION>
```
### Constraints
- First character of response = first character of instruction text
- Instruction: single sentence, ≤ 15 words
- Options: ≤ 7 per question, ≤ 5 words each
- Blank line between instruction and options
- No text before instruction
- No text after last option
- No labels: "Answer:", "Choose:", "Single choice"
- No explanations, comments, status messages, summaries
- No soft offers (see SOFT OFFERS)
- No "Other" option (see OPTIONS AUTHORITY)
### Valid input
```
1 → single select
1,3 → multi-select (comma-separated, no spaces)
abort → emit FLOW_CANCEL
cancel → emit FLOW_CANCEL
<freetext> → pass to system as-is; do not interpret
```
### Invalid input — reject and repeat
```
"option 2"
"I choose 1"
"probably 3"
"1, 3" (spaces in multi-select)
"" (empty)
```
---
## FLOW
### State machine
```
step_1 → step_2 → ... → CONFIRMATION
↑_________ RESTART ________|
```
- One question per step
- Do not proceed until current step receives valid input
- Do not skip steps
- Do not track state — state is injected by system per request
- Do not reorder steps
### Step completion
Step is complete when: input is valid AND value is parsed.
Otherwise: stay on same step.
### Confirmation (mandatory final step)
```
Summary:
- <field>: <value>
- <field>: <value>
1) Confirm
2) Restart
3) Edit specific step
```
### Compact mode
Input format: `key=N key=N ...`
- N must be an exact option number
- Valid fields: apply, skip their steps
- Invalid N for a field: reject that field, ask its step normally
- All fields valid: skip to CONFIRMATION
---
## EXECUTION SILENCE
After receiving valid input, the model MUST execute silently.
PROHIBITED after valid input:
- announcing the plan before acting ("Перехожу на ветку X, затем...")
- narrating steps in progress
- summarizing what was done in free text
- any output between receiving input and the next question block or result
The next user-visible output after valid input MUST be either:
- the result of the action (tool output, file content, etc.), OR
- the next question block in the flow
PROHIBITED pattern:
```
✗ "Перехожу на релизную ветку feat/release-v0.2.9, затем подготовлю версию 0.2.9..."
```
REQUIRED pattern:
```
[executes silently]
→ next question block or action result
```
---
## VALIDATION
### Strictness
- Exact match only
- No partial matches
- No intent interpretation
- No auto-correction
- No implicit defaults
### Retry sequence
```
attempt 1: show error + repeat question
attempt 2: show error + emphasize format (1 / 2 / 1,3)
attempt 3: minimal hint
attempt 4: emit STEP_ABORT
```
Never relax rules across retries.
---
## ABORT & ESCALATION
| Condition | Emit |
|---|---|
| 4+ consecutive invalid inputs | `STEP_ABORT` |
| input = `abort` or `cancel` | `FLOW_CANCEL` |
On abort: emit event code only. No additional text.
---
## SOFT OFFERS
PROHIBITED. Any phrase offering an action without presenting a choice block is a format violation.
```
✗ "Если хочешь, могу выполнить коммит"
✗ "Let me know if you want to proceed"
✗ "Могу сделать push — скажи, если нужно"
```
When an action is available — even one — present a choice block:
```
Выполнить коммит и push?
1) Да
2) Нет
```
---
## OPTIONS AUTHORITY
- Option list MUST be exhaustive
- "Other" is PROHIBITED
- Free-text is always available to the user implicitly — never offer it as an option
- An incomplete option list is a design error; resolve by expanding options or splitting into sub-steps
---
## OUTPUT PURITY
PROHIBITED in any response within scope:
- Reasoning
- Planning
- Analysis
- Status updates
- Progress logs
- Action explanations
Internal reasoning MUST NOT appear in output.
---
## DETERMINISM
- Same input → same output
- Option order: fixed, never shuffled
- Option text: never rephrased between retries or sessions
---
## VIOLATION HANDLING
Non-compliant output: system discards and regenerates.
Model is not notified of regeneration.
---
## PRIORITY
```
1. Safety & ethics — absolute, cannot be overridden
2. Determinism
3. Structure
4. Format compliance
5. User convenience
```

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`.

139
AGENTS.md
View File

@@ -1,95 +1,56 @@
# AGENTS.md
## AGENTS.md
## Описание проекта: LazyBear MCP
### Scope & Source of Truth
- Work in `/`.
- Trust code and project config over `README.md`.
- Primary source of truth: `LazyBear.MCP/Program.cs`.
### Назначение
.NET 10 сервер Model Context Protocol (MCP) для интеграции торговых AI-инструментов.
### Project Facts
- Entry point: `LazyBear.MCP/Program.cs`.
- MCP server registration:
```csharp
AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
```
- Tools are auto-registered from the assembly.
- Current tool groups:
- `LazyBear.MCP/Services/Jira/`
- `LazyBear.MCP/Services/Kubernetes/`
- `Pages/` exists, but Razor Pages are not enabled in `Program.cs`.
- There are no test projects by default.
### Структура проекта
```
LazyBearWorks/
├── LazyBear.MCP/
├── Program.cs # Главный файл с хостингом MCP сервера
│ └── Services/
│ └── TradingTools.cs # Реализация инструментов MCP
├── AGENTS.md # Этот файл, правила для агентов
└── README.md # Документация для пользователей
```
### Commands
- Build: `dotnet build`
- Run: `dotnet run --project LazyBear.MCP`
- MCP inspector: `npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP`
Use only when transport or tool registration changed.
### Основные концепты
### Config & Runtime Gotchas
- Runtime URL: `http://localhost:5000`
- `LazyBear.MCP/Properties/launchSettings.json` shows another port; trust `Program.cs`.
- SDK pin: `LazyBear.MCP/global.json`
- Main config: `LazyBear.MCP/appsettings.json`
- `Jira:Url` is required for Jira tools. If missing, provider init may fail and tools may return string errors.
- `Kubernetes:KubeconfigPath` may be empty. Fallback order:
1. explicit kubeconfig
2. default kubeconfig
3. in-cluster config
- Never print or commit real secrets, tokens, kubeconfig contents, or private URLs.
**Масштабирование (Hosting):**
- ASP.NET Core веб-приложение (.NET 10.0)
- HTTP transport для удалённой MCP коммуникации
- Авто-обнаружение `[McpServerToolType]` и `[McpServerResourceType]`
### Working Rules
- 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.
**Технологический стек:**
- **Runtime:** .NET 10.0
- **Framework:** ASP.NET Core
- **SDK:** ModelContextProtocol.AspNetCore 1.2.0
- **AI:** Microsoft.Extensions.AI
**Особенности MCP:**
- **Инструменты (Tools):** Статические классы с атрибутами `[McpServerTool]`
- **Ресурсы (Resources):** Шаблоны ресурсов с атрибутами `[McpServerResource]`
- **Промпты (Prompts):** Параметризованные промпты (в разработке)
### Запуск
**Запустить сервер:**
```bash
dotnet run --project LazyBear.MCP
```
**Собрать проект:**
```bash
dotnet build
```
### Пакеты
- `ModelContextProtocol` - основной SDK
- `ModelContextProtocol.AspNetCore` - интеграция с ASP.NET Core
- `Microsoft.Extensions.AI` - абстракции AI
---
## Правила для машин (MACHINE-FIRST)
### Обязательные правила (MUST)
**Перед модификацией:**
1. Прочитать существующий код
2. Сохранять текущий стиль и паттерны
3. Минимизировать изменения
4. Не добавлять секреты в код
**Перед коммитом:**
1. Сборка `dotnet build` должна проходить локально
2. Изменения не должны ломать MCP протокол
3. Код в Git только после проверки сборки
4. Комментарии, документация и коммиты — только на русском
### Build & Deploy
- Локальная сборка обязана проходить
- Без ломающих изменений в MCP
- Коммиты с проверкой сборки
---
## Модель выполнения (MUST)
**Приоритеты инструкции:**
1. Инструкции пользователя
2. AGENTS.md (этот файл)
3. Упомянутые общие правила
4. Существующий стиль кода
5. Лучшие практики
**Переиспользуемые правила:**
- [AGENT.common.md](AGENT.common.md) - общие правила
- [AGENT.interaction.md](AGENT.interaction.md) - правила взаимодействия
### Поддержание правил (MUST)
- Минимальные обновления
- Объединение дубликатов
- Сохранение смысла
### 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

@@ -1,16 +0,0 @@
# ASP.NET Core
bin/
obj/
*.user
*.suo
*.cache
# Visual Studio
.vs/
# Rider
.idea/
# Microsoft
PublishFiles/
temp

View File

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

View File

@@ -1,30 +1,98 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
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);
// ── Общий логгер и один DI-контейнер для TUI + MCP ──────────────────────────
var logSink = new InMemoryLogSink();
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services.AddSingleton(logSink);
services.AddSingleton<ToolRegistryService>();
var app = builder.Build();
// MCP-провайдеры
services.AddSingleton<K8sClientProvider>();
services.AddSingleton<JiraClientProvider>();
services.AddSingleton<ConfluenceClientProvider>();
services.AddSingleton<GitLabClientProvider>();
services.AddSingleton<QdrantClientProvider>();
app.MapMcp();
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
services.AddSingleton<IToolModule, JiraToolModule>();
services.AddSingleton<IToolModule, KubernetesToolModule>();
services.AddSingleton<IToolModule, ConfluenceToolModule>();
services.AddSingleton<IToolModule, GitLabToolModule>();
services.AddSingleton<IToolModule, QdrantToolModule>();
app.Run("http://localhost:5000");
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
services.AddHostedService<McpWebHostedService>();
[McpServerToolType]
public static class TradingTools
// Глобальный читатель клавиш — единственный источник клавишных событий для TUI
services.AddSingleton<GlobalKeyboardService>();
services.AddHostedService(sp => sp.GetRequiredService<GlobalKeyboardService>());
// Локализация 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.
}
try
{
Console.Write("\u001b[?25l");
Console.Out.Flush();
}
catch
{
// Ignore terminals that do not support ANSI cursor control.
}
return Task.CompletedTask;
};
});
});
})
.Build();
// ── Регистрируем модули один раз до старта TUI и web host ───────────────────
var registry = host.Services.GetRequiredService<ToolRegistryService>();
foreach (var module in host.Services.GetServices<IToolModule>())
{
[McpServerTool, Description("Получить текущую цену актива")]
public static string GetCurrentPrice([Description("Тикер актива")] string ticker)
{
return $"Цена {ticker}: 50000 USD (фейковые данные)";
}
[McpServerTool, Description("Получить информацию о позиции")]
public static string GetPositionInfo([Description("ID позиции")] string positionId)
{
return $"Позиция {positionId}: Long BTC, PnL: +500 USD";
}
registry.RegisterModule(module);
}
await host.RunAsync();

View File

@@ -4,10 +4,11 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5079",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5152"
}
}
}

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,25 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Confluence;
public static class ConfluenceClientFactory
{
public static RestClient CreateClient(IConfiguration configuration)
{
var confluenceUrl = configuration["Confluence:Url"] ?? "";
if (string.IsNullOrWhiteSpace(confluenceUrl))
{
throw new Exception("Confluence:Url нe задан");
}
var config = new RestClientOptions(confluenceUrl)
{
UserAgent = "LazyBear-Confluence-MCP",
Timeout = TimeSpan.FromMilliseconds(30000)
};
return new RestClient(config);
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Confluence;
public sealed class ConfluenceClientProvider
{
public RestClient? Client { get; }
public string? InitializationError { get; }
public ConfluenceClientProvider(IConfiguration configuration)
{
try
{
Client = ConfluenceClientFactory.CreateClient(configuration);
}
catch (Exception ex)
{
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,428 @@
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.Confluence;
[McpServerToolType]
public sealed class ConfluencePagesTools(
ConfluenceClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly RestClient? _client = provider.Client;
private readonly string? _clientInitializationError = provider.InitializationError;
private readonly string _token = configuration["Confluence:Token"] ?? string.Empty;
private readonly string _username = configuration["Confluence:Username"] ?? string.Empty;
private readonly string _spaceKey = configuration["Confluence:SpaceKey"] ?? string.Empty;
private const string ModuleName = "Confluence";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Confluence отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить страницу Confluence по ID")]
public async Task<string> GetPage(
[Description("ID страницы Confluence")] string pageId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetPage", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(pageId))
{
return "ID страницы Confluence не задан.";
}
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/wiki/api/v2/pages/{pageId}");
request.AddQueryParameter("expand", "body.storage,version");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_page", response, pageId);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var title = GetNestedString(root, "title") ?? "-";
var version = GetNestedLong(root, "version", "number");
var updatedAt = GetNestedString(root, "version", "when");
var url = GetNestedString(root, "_links", "webui");
return $"Страница Confluence: title={title}, id={pageId}, version={version}, updated={updatedAt}, url={url}";
}
catch (Exception ex)
{
return FormatException("get_page", ex, pageId);
}
}
[McpServerTool, Description("Поиск страниц Confluence по CQL запросу")]
public async Task<string> SearchPages(
[Description("CQL запрос. Если пусто, используется пространство по умолчанию")] string? cql = null,
[Description("Максимум страниц в ответе")] int maxResults = 20,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("SearchPages", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
var resolvedCql = ResolveCql(cql);
if (string.IsNullOrWhiteSpace(resolvedCql))
{
return "CQL не задан и Confluence:SpaceKey не настроен.";
}
try
{
var request = CreateRequest("/wiki/rest/api/content/search");
request.AddQueryParameter("cql", resolvedCql);
request.AddQueryParameter("limit", Math.Max(1, maxResults).ToString());
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("search_pages", response, resolvedCql);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("results", out var resultsElement) || resultsElement.GetArrayLength() == 0)
{
return "Страницы Confluence не найдены.";
}
var lines = new List<string>();
foreach (var result in resultsElement.EnumerateArray())
{
var id = GetNestedString(result, "id") ?? "-";
var title = GetNestedString(result, "title") ?? "-";
var type = GetNestedString(result, "type") ?? "-";
var space = GetNestedString(result, "space", "name") ?? "-";
lines.Add($"{id}: {title} [{space}/{type}]");
}
return $"Страницы Confluence по CQL '{resolvedCql}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("search_pages", ex, resolvedCql);
}
}
[McpServerTool, Description("Создать новую страницу Confluence")]
public async Task<string> CreatePage(
[Description("Название страницы")] string title,
[Description("Содержимое страницы в формате storage (HTML-like WikiMarkup)")] string bodyStorage,
[Description("Ключ пространства. Если пусто, используется Confluence:SpaceKey")] string? spaceKey = null,
[Description("ID родительской страницы. Опционально")] string? parentId = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreatePage", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
var resolvedSpace = string.IsNullOrWhiteSpace(spaceKey) ? _spaceKey : spaceKey;
if (string.IsNullOrWhiteSpace(resolvedSpace))
{
return "Пространство Confluence не задано. Укажите spaceKey или настройте Confluence:SpaceKey.";
}
try
{
var request = CreateRequest("/wiki/rest/api/content", Method.Post);
var bodyObj = new Dictionary<string, object>
{
["type"] = "page",
["title"] = title,
["space"] = new Dictionary<string, object> { ["key"] = resolvedSpace },
["body"] = new Dictionary<string, object>
{
["storage"] = new Dictionary<string, object>
{
["value"] = bodyStorage,
["representation"] = "storage"
}
}
};
if (!string.IsNullOrWhiteSpace(parentId))
{
bodyObj["ancestors"] = new[] { new Dictionary<string, object> { ["id"] = parentId } };
}
request.AddJsonBody(bodyObj);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_page", response, title);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var createdId = GetNestedString(root, "id") ?? "-";
var createdUrl = GetNestedString(root, "_links", "webui") ?? "-";
return $"Страница Confluence создана: id={createdId}, title={title}, url={createdUrl}";
}
catch (Exception ex)
{
return FormatException("create_page", ex, title);
}
}
[McpServerTool, Description("Обновить содержимое страницы Confluence")]
public async Task<string> UpdatePage(
[Description("ID страницы для обновления")] string pageId,
[Description("Новое содержимое страницы в формате storage (HTML-like WikiMarkup)")] string bodyStorage,
[Description("Номер текущей версии страницы")] int version,
[Description("Новое название страницы. Если пусто, не меняется")] string? title = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpdatePage", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
// Если title не передан — нужно получить текущий
string resolvedTitle;
if (!string.IsNullOrWhiteSpace(title))
{
resolvedTitle = title;
}
else
{
var getRequest = CreateRequest($"/wiki/rest/api/content/{pageId}");
var getResponse = await client.ExecuteAsync(getRequest, cancellationToken);
if (!getResponse.IsSuccessful || string.IsNullOrWhiteSpace(getResponse.Content))
{
return FormatResponseError("update_page_get_title", getResponse, pageId);
}
using var getDoc = JsonDocument.Parse(getResponse.Content);
resolvedTitle = GetNestedString(getDoc.RootElement, "title") ?? "Untitled";
}
try
{
var request = CreateRequest($"/wiki/rest/api/content/{pageId}", Method.Put);
request.AddJsonBody(new
{
version = new { number = version + 1 },
title = resolvedTitle,
type = "page",
body = new
{
storage = new
{
value = bodyStorage,
representation = "storage"
}
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("update_page", response, pageId);
}
return $"Страница Confluence '{pageId}' обновлена.";
}
catch (Exception ex)
{
return FormatException("update_page", ex, pageId);
}
}
[McpServerTool, Description("Удалить страницу Confluence")]
public async Task<string> DeletePage(
[Description("ID страницы для удаления")] string pageId,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeletePage", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/wiki/rest/api/content/{pageId}", Method.Delete);
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("delete_page", response, pageId);
}
return $"Страница Confluence '{pageId}' удалена.";
}
catch (Exception ex)
{
return FormatException("delete_page", ex, pageId);
}
}
[McpServerTool, Description("Получить пространство Confluence по ключу")]
public async Task<string> GetSpace(
[Description("Ключ пространства")] string spaceKey,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetSpace", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/wiki/rest/api/space/{spaceKey}");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_space", response, spaceKey);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var name = GetNestedString(root, "name") ?? "-";
var key = GetNestedString(root, "key") ?? "-";
var homepageId = GetNestedString(root, "homepage", "id");
return $"Пространство Confluence: key={key}, name={name}, homepageId={homepageId}";
}
catch (Exception ex)
{
return FormatException("get_space", ex, spaceKey);
}
}
private string? ResolveCql(string? cql)
{
if (!string.IsNullOrWhiteSpace(cql))
{
return cql;
}
return string.IsNullOrWhiteSpace(_spaceKey) ? null : $"space = '{_spaceKey}' ORDER BY lastmodified DESC";
}
private RestRequest CreateRequest(string resource, Method method = Method.Get)
{
var request = new RestRequest(resource, method);
request.AddHeader("Accept", "application/json");
if (!string.IsNullOrWhiteSpace(_username) && !string.IsNullOrWhiteSpace(_token))
{
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{_username}:{_token}"));
request.AddHeader("Authorization", $"Basic {credentials}");
}
else if (!string.IsNullOrWhiteSpace(_token))
{
request.AddHeader("Authorization", $"Bearer {_token}");
}
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 = "Confluence клиент не инициализирован. Проверьте Confluence: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 long? GetNestedLong(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.Number ? current.GetInt64() : (long?)null;
}
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 $"Ошибка Confluence в 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 $"Ошибка Confluence в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
}

View File

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

View File

@@ -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,31 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Jira;
public static class JiraClientFactory
{
private static readonly TimeSpan[] BackoffDurations = {
TimeSpan.FromMilliseconds(1000),
TimeSpan.FromMilliseconds(2000),
TimeSpan.FromMilliseconds(4000)
};
public static RestClient CreateClient(IConfiguration configuration)
{
var jiraUrl = configuration["Jira:Url"] ?? "";
if (string.IsNullOrWhiteSpace(jiraUrl))
{
throw new Exception("Jira:Url не задан");
}
var config = new RestClientOptions(jiraUrl)
{
UserAgent = "LazyBear-Jira-MCP",
Timeout = TimeSpan.FromMilliseconds(30000)
};
return new RestClient(config);
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Jira;
public sealed class JiraClientProvider
{
public RestClient? Client { get; }
public string? InitializationError { get; }
public JiraClientProvider(IConfiguration configuration)
{
try
{
Client = JiraClientFactory.CreateClient(configuration);
}
catch (Exception ex)
{
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,539 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.Jira;
[McpServerToolType]
public sealed class JiraIssueTools(
JiraClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly RestClient? _client = provider.Client;
private readonly string? _clientInitializationError = provider.InitializationError;
private readonly string _token = configuration["Jira:Token"] ?? string.Empty;
private readonly string _defaultProject = configuration["Jira:Project"] ?? string.Empty;
private const string ModuleName = "Jira";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Jira отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить задачу Jira по ключу")]
public async Task<string> GetIssue(
[Description("Ключ задачи, например PROJ-123")] string issueKey,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetIssue", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(issueKey))
{
return "Ключ задачи Jira не задан.";
}
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/rest/api/3/issue/{issueKey}");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_issue", response, issueKey);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var summary = GetNestedString(root, "fields", "summary") ?? "-";
var status = GetNestedString(root, "fields", "status", "name") ?? "-";
var issueType = GetNestedString(root, "fields", "issuetype", "name") ?? "-";
var assignee = GetNestedString(root, "fields", "assignee", "displayName") ?? "unassigned";
return $"Задача '{issueKey}': summary={summary}, status={status}, type={issueType}, assignee={assignee}";
}
catch (Exception ex)
{
return FormatException("get_issue", ex, issueKey);
}
}
[McpServerTool, Description("Список задач Jira по JQL")]
public async Task<string> ListIssues(
[Description("JQL запрос. Если пусто, используется проект по умолчанию")] string? jql = null,
[Description("Максимум задач в ответе")] int maxResults = 20,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssues", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
var resolvedJql = ResolveJql(jql);
if (string.IsNullOrWhiteSpace(resolvedJql))
{
return "JQL не задан и Jira:Project не настроен.";
}
try
{
var request = CreateRequest("/rest/api/3/search/jql");
request.AddQueryParameter("jql", resolvedJql);
request.AddQueryParameter("maxResults", Math.Max(1, maxResults).ToString());
request.AddQueryParameter("fields", "summary,status,issuetype");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_issues", response, resolvedJql);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0)
{
return "Задачи Jira не найдены.";
}
var lines = new List<string>();
foreach (var issue in issuesElement.EnumerateArray())
{
var key = issue.TryGetProperty("key", out var keyElement) ? keyElement.GetString() ?? "unknown" : "unknown";
var summary = GetNestedString(issue, "fields", "summary") ?? "-";
var status = GetNestedString(issue, "fields", "status", "name") ?? "-";
lines.Add($"{key}: {summary} [{status}]");
}
return $"Задачи Jira по JQL '{resolvedJql}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("list_issues", ex, resolvedJql);
}
}
[McpServerTool, Description("Создать задачу Jira")]
public async Task<string> CreateIssue(
[Description("Summary задачи")] string summary,
[Description("Ключ проекта. Если пусто, используется Jira:Project")] string? projectKey = null,
[Description("Тип задачи, например Task или Bug")] string issueType = "Task",
[Description("Описание задачи")] string? description = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateIssue", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
var resolvedProject = string.IsNullOrWhiteSpace(projectKey) ? _defaultProject : projectKey;
if (string.IsNullOrWhiteSpace(resolvedProject))
{
return "Проект Jira не задан. Укажите projectKey или настройте Jira:Project.";
}
try
{
var request = CreateRequest("/rest/api/3/issue", Method.Post);
request.AddJsonBody(new
{
fields = new
{
project = new { key = resolvedProject },
summary,
issuetype = new { name = issueType },
description = string.IsNullOrWhiteSpace(description)
? null
: new
{
type = "doc",
version = 1,
content = new object[]
{
new
{
type = "paragraph",
content = new object[]
{
new
{
type = "text",
text = description
}
}
}
}
}
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("create_issue", response, resolvedProject);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var createdKey = root.TryGetProperty("key", out var keyElement) ? keyElement.GetString() ?? "-" : "-";
return $"Задача Jira создана: {createdKey}";
}
catch (Exception ex)
{
return FormatException("create_issue", ex, resolvedProject);
}
}
[McpServerTool, Description("Обновить summary или описание задачи Jira")]
public async Task<string> UpdateIssue(
[Description("Ключ задачи")] string issueKey,
[Description("Новый summary")] string? summary = null,
[Description("Новое описание")] string? description = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpdateIssue", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description))
{
return $"Нет полей для обновления задачи '{issueKey}'.";
}
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var fields = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(summary))
{
fields["summary"] = summary;
}
if (!string.IsNullOrWhiteSpace(description))
{
fields["description"] = new
{
type = "doc",
version = 1,
content = new object[]
{
new
{
type = "paragraph",
content = new object[]
{
new
{
type = "text",
text = description
}
}
}
}
};
}
var request = CreateRequest($"/rest/api/3/issue/{issueKey}", Method.Put);
request.AddJsonBody(new { fields });
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("update_issue", response, issueKey);
}
return $"Задача Jira '{issueKey}' обновлена.";
}
catch (Exception ex)
{
return FormatException("update_issue", ex, issueKey);
}
}
[McpServerTool, Description("Доступные переходы статуса для задачи Jira")]
public async Task<string> GetIssueStatuses(
[Description("Ключ задачи")] string issueKey,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetIssueStatuses", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/rest/api/3/issue/{issueKey}/transitions");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("get_issue_statuses", response, issueKey);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("transitions", out var transitionsElement) || transitionsElement.GetArrayLength() == 0)
{
return $"Для задачи '{issueKey}' нет доступных переходов.";
}
var lines = new List<string>();
foreach (var transition in transitionsElement.EnumerateArray())
{
var name = transition.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "-" : "-";
var targetStatus = GetNestedString(transition, "to", "name") ?? "-";
lines.Add($"{name} -> {targetStatus}");
}
return $"Переходы статуса для '{issueKey}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("get_issue_statuses", ex, issueKey);
}
}
[McpServerTool, Description("Список комментариев задачи Jira")]
public async Task<string> ListIssueComments(
[Description("Ключ задачи")] string issueKey,
[Description("Максимум комментариев")] int limit = 20,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIssueComments", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/rest/api/3/issue/{issueKey}/comment");
request.AddQueryParameter("maxResults", Math.Max(1, limit).ToString());
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_issue_comments", response, issueKey);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
if (!root.TryGetProperty("comments", out var commentsElement) || commentsElement.GetArrayLength() == 0)
{
return $"Комментарии к задаче '{issueKey}' отсутствуют.";
}
var lines = new List<string>();
foreach (var comment in commentsElement.EnumerateArray())
{
var id = comment.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? "-" : "-";
var author = GetNestedString(comment, "author", "displayName") ?? "unknown";
var text = ExtractCommentText(comment.GetProperty("body"));
lines.Add($"{id}: {author} -> {text}");
}
return $"Комментарии задачи '{issueKey}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("list_issue_comments", ex, issueKey);
}
}
[McpServerTool, Description("Добавить комментарий к задаче Jira")]
public async Task<string> AddComment(
[Description("Ключ задачи")] string issueKey,
[Description("Текст комментария")] string body,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("AddComment", out var enabledError)) return enabledError;
if (string.IsNullOrWhiteSpace(body))
{
return "Текст комментария Jira не задан.";
}
if (!TryGetClient(out var client, out var error))
{
return error;
}
try
{
var request = CreateRequest($"/rest/api/3/issue/{issueKey}/comment", Method.Post);
request.AddJsonBody(new
{
body = new
{
type = "doc",
version = 1,
content = new object[]
{
new
{
type = "paragraph",
content = new object[]
{
new
{
type = "text",
text = body
}
}
}
}
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("add_comment", response, issueKey);
}
using var document = JsonDocument.Parse(response.Content);
var root = document.RootElement;
var commentId = root.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? "-" : "-";
return $"Комментарий добавлен к задаче '{issueKey}', id={commentId}.";
}
catch (Exception ex)
{
return FormatException("add_comment", ex, issueKey);
}
}
private string? ResolveJql(string? jql)
{
if (!string.IsNullOrWhiteSpace(jql))
{
return jql;
}
return string.IsNullOrWhiteSpace(_defaultProject) ? null : $"project = {_defaultProject} ORDER BY updated DESC";
}
private RestRequest CreateRequest(string resource, Method method = Method.Get)
{
var request = new RestRequest(resource, method);
request.AddHeader("Accept", "application/json");
if (!string.IsNullOrWhiteSpace(_token))
{
request.AddHeader("Authorization", $"Bearer {_token}");
}
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 = "Jira клиент не инициализирован. Проверьте Jira: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 ExtractCommentText(JsonElement body)
{
var chunks = new List<string>();
CollectText(body, chunks);
return chunks.Count == 0 ? "-" : string.Join(" ", chunks);
}
private static void CollectText(JsonElement element, List<string> chunks)
{
if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty("text", out var textElement) && 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);
}
}
}
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 $"Ошибка Jira в 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 $"Ошибка Jira в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
}

View File

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

View File

@@ -0,0 +1,33 @@
using k8s;
using Microsoft.Extensions.Configuration;
namespace LazyBear.MCP.Services.Kubernetes;
public static class K8sClientFactory
{
public static IKubernetes CreateClient(IConfiguration configuration)
{
var kubeconfigPath = configuration["Kubernetes:KubeconfigPath"];
KubernetesClientConfiguration clientConfiguration;
if (!string.IsNullOrWhiteSpace(kubeconfigPath))
{
var expandedPath = Environment.ExpandEnvironmentVariables(kubeconfigPath);
var fullPath = Path.GetFullPath(expandedPath);
clientConfiguration = KubernetesClientConfiguration.BuildConfigFromConfigFile(fullPath);
return new global::k8s.Kubernetes(clientConfiguration);
}
try
{
clientConfiguration = KubernetesClientConfiguration.BuildConfigFromConfigFile();
}
catch
{
clientConfiguration = KubernetesClientConfiguration.InClusterConfig();
}
return new global::k8s.Kubernetes(clientConfiguration);
}
}

View File

@@ -0,0 +1,23 @@
using k8s;
using Microsoft.Extensions.Configuration;
namespace LazyBear.MCP.Services.Kubernetes;
public sealed class K8sClientProvider
{
public IKubernetes? Client { get; }
public string? InitializationError { get; }
public K8sClientProvider(IConfiguration configuration)
{
try
{
Client = K8sClientFactory.CreateClient(configuration);
}
catch (Exception ex)
{
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,178 @@
using System.ComponentModel;
using k8s;
using k8s.Autorest;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sConfigTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sConfigTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
private const int MaxSecretKeyLimit = 100;
[McpServerTool, Description("Список ConfigMap в namespace")]
public async Task<string> ListConfigMaps(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListConfigMaps", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(ns, cancellationToken: cancellationToken);
if (configMaps.Items.Count == 0)
{
return $"В namespace '{ns}' configMap не найдены.";
}
var lines = configMaps.Items.Select(cm =>
{
var name = cm.Metadata?.Name ?? "unknown";
var keyCount = cm.Data?.Count ?? 0;
return $"{name}: keys={keyCount}";
});
return $"ConfigMap namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("list_config_maps", ns, ex);
}
}
[McpServerTool, Description("Получить данные ConfigMap")]
public async Task<string> GetConfigMapData(
[Description("Имя ConfigMap")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetConfigMapData", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(name, ns, cancellationToken: cancellationToken);
if (configMap.Data is null || configMap.Data.Count == 0)
{
return $"ConfigMap '{name}' в namespace '{ns}' не содержит данных.";
}
var lines = configMap.Data.Select(item => $"{item.Key}={item.Value}");
return $"Данные ConfigMap '{name}' namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("get_config_map_data", ns, ex, name);
}
}
[McpServerTool, Description("Список Secret в namespace (без значений)")]
public async Task<string> ListSecrets(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListSecrets", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var secrets = await client.CoreV1.ListNamespacedSecretAsync(ns, cancellationToken: cancellationToken);
if (secrets.Items.Count == 0)
{
return $"В namespace '{ns}' secret не найдены.";
}
var lines = secrets.Items.Select(secret =>
{
var name = secret.Metadata?.Name ?? "unknown";
var type = secret.Type ?? "Opaque";
return $"{name}: type={type}";
});
return $"Secret namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("list_secrets", ns, ex);
}
}
[McpServerTool, Description("Ключи Secret без значений")]
public async Task<string> GetSecretKeys(
[Description("Имя Secret")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetSecretKeys", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var secret = await client.CoreV1.ReadNamespacedSecretAsync(name, ns, cancellationToken: cancellationToken);
var keys = new HashSet<string>(StringComparer.Ordinal);
if (secret.Data is not null)
{
foreach (var key in secret.Data.Keys)
{
keys.Add(key);
}
}
if (secret.StringData is not null)
{
foreach (var key in secret.StringData.Keys)
{
keys.Add(key);
}
}
if (keys.Count == 0)
{
return $"Secret '{name}' в namespace '{ns}' не содержит ключей.";
}
return $"Ключи Secret '{name}' namespace '{ns}': {string.Join(", ", keys.OrderBy(k => k))}";
}
catch (Exception ex)
{
return FormatError("get_secret_keys", ns, ex, name);
}
}
}

View File

@@ -0,0 +1,180 @@
using System.ComponentModel;
using System.Text.Json;
using k8s;
using k8s.Autorest;
using k8s.Models;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sDeploymentTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sDeploymentTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
private const int MinReplicas = 0;
private const int MaxReplicas = 100;
[McpServerTool, Description("Список deployment в namespace")]
public async Task<string> ListDeployments(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListDeployments", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var deployments = await client.AppsV1.ListNamespacedDeploymentAsync(ns, cancellationToken: cancellationToken);
if (deployments.Items.Count == 0)
{
return $"В namespace '{ns}' deployment не найдены.";
}
var lines = deployments.Items.Select(dep =>
{
var name = dep.Metadata?.Name ?? "unknown";
var desired = dep.Spec?.Replicas ?? 0;
var ready = dep.Status?.ReadyReplicas ?? 0;
var available = dep.Status?.AvailableReplicas ?? 0;
return $"{name}: desired={desired}, ready={ready}, available={available}";
});
return $"Deployment namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("list_deployments", ns, ex);
}
}
[McpServerTool, Description("Масштабировать deployment")]
public async Task<string> ScaleDeployment(
[Description("Имя deployment")] string name,
[Description("Новое количество реплик")] int replicas,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ScaleDeployment", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
if (replicas < MinReplicas || replicas > MaxReplicas)
{
return $"Invalid replicas value: {replicas}. Must be between {MinReplicas} and {MaxReplicas}.";
}
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, ns, cancellationToken: cancellationToken);
deployment.Spec ??= new V1DeploymentSpec();
deployment.Spec.Replicas = replicas;
await client.AppsV1.ReplaceNamespacedDeploymentAsync(deployment, name, ns, cancellationToken: cancellationToken);
return $"Deployment '{name}' в namespace '{ns}' масштабирован до {replicas} реплик.";
}
catch (Exception ex)
{
return FormatError("scale_deployment", ns, ex, name);
}
}
[McpServerTool, Description("Статус rollout deployment")]
public async Task<string> GetRolloutStatus(
[Description("Имя deployment")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetRolloutStatus", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var deployment = await client.AppsV1.ReadNamespacedDeploymentStatusAsync(name, ns, cancellationToken: cancellationToken);
var desired = deployment.Spec?.Replicas ?? 0;
var updated = deployment.Status?.UpdatedReplicas ?? 0;
var ready = deployment.Status?.ReadyReplicas ?? 0;
var available = deployment.Status?.AvailableReplicas ?? 0;
return $"Rollout '{name}' в namespace '{ns}': desired={desired}, updated={updated}, ready={ready}, available={available}";
}
catch (Exception ex)
{
return FormatError("get_rollout_status", ns, ex, name);
}
}
[McpServerTool, Description("Перезапустить deployment (rolling restart)")]
public async Task<string> RestartDeployment(
[Description("Имя deployment")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("RestartDeployment", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var patchJson = JsonSerializer.Serialize(new
{
spec = new
{
template = new
{
metadata = new
{
annotations = new Dictionary<string, string>
{
["kubectl.kubernetes.io/restartedAt"] = DateTimeOffset.UtcNow.ToString("O")
}
}
}
}
});
var patch = new V1Patch(patchJson, V1Patch.PatchType.StrategicMergePatch);
await client.AppsV1.PatchNamespacedDeploymentAsync(patch, name, ns, cancellationToken: cancellationToken);
return $"Deployment '{name}' в namespace '{ns}' отправлен на rolling restart.";
}
catch (Exception ex)
{
return FormatError("restart_deployment", ns, ex, name);
}
}
}

View File

@@ -0,0 +1,151 @@
using System.ComponentModel;
using k8s;
using k8s.Autorest;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sNetworkTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sNetworkTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
[McpServerTool, Description("Список service в namespace")]
public async Task<string> ListServices(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListServices", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var services = await client.CoreV1.ListNamespacedServiceAsync(ns, cancellationToken: cancellationToken);
if (services.Items.Count == 0)
{
return $"В namespace '{ns}' service не найдены.";
}
var lines = services.Items.Select(svc =>
{
var name = svc.Metadata?.Name ?? "unknown";
var type = svc.Spec?.Type ?? "ClusterIP";
var clusterIp = svc.Spec?.ClusterIP ?? "-";
var ports = svc.Spec?.Ports is null
? "-"
: string.Join(",", svc.Spec.Ports.Select(p => $"{p.Port}/{p.Protocol}"));
return $"{name}: type={type}, clusterIp={clusterIp}, ports={ports}";
});
return $"Services namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("list_services", ns, ex);
}
}
[McpServerTool, Description("Детали service")]
public async Task<string> GetServiceDetails(
[Description("Имя service")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetServiceDetails", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var service = await client.CoreV1.ReadNamespacedServiceAsync(name, ns, cancellationToken: cancellationToken);
var type = service.Spec?.Type ?? "ClusterIP";
var clusterIp = service.Spec?.ClusterIP ?? "-";
var externalIps = service.Spec?.ExternalIPs is null ? "-" : string.Join(",", service.Spec.ExternalIPs);
var selector = service.Spec?.Selector is null
? "-"
: string.Join(",", service.Spec.Selector.Select(x => $"{x.Key}={x.Value}"));
var ports = service.Spec?.Ports is null
? "-"
: string.Join(",", service.Spec.Ports.Select(p => $"{p.Name ?? "port"}:{p.Port}->{p.TargetPort}"));
return $"Service '{name}' namespace '{ns}': type={type}, clusterIp={clusterIp}, externalIps={externalIps}, selector={selector}, ports={ports}";
}
catch (Exception ex)
{
return FormatError("get_service_details", ns, ex, name);
}
}
[McpServerTool, Description("Список ingress в namespace")]
public async Task<string> ListIngresses(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListIngresses", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(ns, cancellationToken: cancellationToken);
if (ingresses.Items.Count == 0)
{
return $"В namespace '{ns}' ingress не найдены.";
}
var lines = ingresses.Items.Select(ing =>
{
var name = ing.Metadata?.Name ?? "unknown";
var rules = ing.Spec?.Rules is null
? "-"
: string.Join(";", ing.Spec.Rules.Select(r =>
{
var host = string.IsNullOrWhiteSpace(r.Host) ? "*" : r.Host;
var paths = r.Http?.Paths is null
? "-"
: string.Join(",", r.Http.Paths.Select(p =>
{
var serviceName = p.Backend?.Service?.Name ?? "-";
var servicePort = p.Backend?.Service?.Port?.Number?.ToString() ?? p.Backend?.Service?.Port?.Name ?? "-";
return $"{p.Path ?? "/"}->{serviceName}:{servicePort}";
}));
return $"{host}:{paths}";
}));
return $"{name}: rules={rules}";
});
return $"Ingress namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("list_ingresses", ns, ex);
}
}
}

View File

@@ -0,0 +1,147 @@
using System.ComponentModel;
using k8s;
using k8s.Autorest;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public sealed class K8sPodsTools(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger<K8sPodsTools>? logger = null)
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
{
private const int MaxTailLines = 10;
private const int MinTailLines = 10;
[McpServerTool, Description("Список подов в namespace")]
public async Task<string> ListPods(
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListPods", out var enabledError)) return enabledError;
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var pods = await client.CoreV1.ListNamespacedPodAsync(ns, cancellationToken: cancellationToken);
if (pods.Items.Count == 0)
{
return $"В namespace '{ns}' поды не найдены.";
}
var lines = pods.Items.Select(pod =>
{
var podName = pod.Metadata?.Name ?? "unknown";
var phase = pod.Status?.Phase ?? "Unknown";
var node = pod.Spec?.NodeName ?? "-";
var restarts = pod.Status?.ContainerStatuses?.Sum(s => s.RestartCount) ?? 0;
return $"{podName}: phase={phase}, restarts={restarts}, node={node}";
});
return $"Поды namespace '{ns}':\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatError("list_pods", ns, ex);
}
}
[McpServerTool, Description("Статус pod по имени")]
public async Task<string> GetPodStatus(
[Description("Имя pod")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetPodStatus", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
var pod = await client.CoreV1.ReadNamespacedPodAsync(name, ns, cancellationToken: cancellationToken);
var phase = pod.Status?.Phase ?? "Unknown";
var hostIp = pod.Status?.HostIP ?? "-";
var podIp = pod.Status?.PodIP ?? "-";
var conditions = pod.Status?.Conditions?.Select(c => $"{c.Type}={c.Status}") ?? [];
return $"Pod '{name}' в namespace '{ns}': phase={phase}, hostIp={hostIp}, podIp={podIp}, conditions=[{string.Join(", ", conditions)}]";
}
catch (Exception ex)
{
return FormatError("get_pod_status", ns, ex, name);
}
}
[McpServerTool, Description("Логи pod")]
public async Task<string> GetPodLogs(
[Description("Имя pod")] string name,
[Description("Namespace Kubernetes")] string? @namespace = null,
[Description("Имя контейнера")]
string? container = null,
[Description("Количество строк с конца")]
int? tailLines = 100,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("GetPodLogs", out var enabledError)) return enabledError;
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
if (tailLines < MinTailLines)
{
tailLines = MinTailLines;
}
if (tailLines > MaxTailLines)
{
tailLines = MaxTailLines;
}
var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError))
{
return clientError;
}
try
{
await using var logStream = await client.CoreV1.ReadNamespacedPodLogAsync(
name,
ns,
container: container,
tailLines: (int?)tailLines,
cancellationToken: cancellationToken);
using var reader = new StreamReader(logStream);
var logs = await reader.ReadToEndAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(logs))
{
return $"Логи pod '{name}' в namespace '{ns}' пустые.";
}
return logs;
}
catch (Exception ex)
{
return FormatError("get_pod_logs", ns, ex, name);
}
}
}

View File

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

View File

@@ -0,0 +1,125 @@
using System.Text.RegularExpressions;
using k8s;
using LazyBear.MCP.Services.ToolRegistry;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType]
public abstract class KubernetesToolsBase(
K8sClientProvider clientProvider,
IConfiguration configuration,
ToolRegistryService registry,
ILogger? logger = null)
{
protected readonly IKubernetes? _client = clientProvider.Client;
protected readonly string? _clientInitializationError = clientProvider.InitializationError;
protected readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
protected readonly ILogger? _logger = logger;
protected readonly ToolRegistryService _registry = registry;
protected const string K8sModuleName = "Kubernetes";
protected bool TryCheckEnabled(string toolName, out string error)
{
if (!_registry.IsToolEnabled(K8sModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Kubernetes отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
protected static readonly Regex NamespaceRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
protected static readonly Regex ResourceNameRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
protected string ResolveNamespace(string? @namespace)
{
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
}
protected void ValidateNamespace(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"Namespace не может быть пустым", parameterName);
}
if (value.Length > 63)
{
throw new ArgumentException($"Namespace не может быть длиннее 63 символов", parameterName);
}
if (!NamespaceRegex.IsMatch(value))
{
throw new ArgumentException($"Невалидный формат namespace: '{value}'. Только lowercase alphanumetic, -, не может начинаться/заканчиваться на -", parameterName);
}
}
protected void ValidateResourceName(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException($"Resource name не может быть пустым", parameterName);
}
if (value.Length > 253)
{
throw new ArgumentException($"Resource name не может быть длиннее 253 символов", parameterName);
}
if (!ResourceNameRegex.IsMatch(value))
{
throw new ArgumentException($"Невалидный формат: '{value}'. Только lowercase alphanumetic, -, не может начинаться/заканчиваться на -", parameterName);
}
}
protected bool TryGetClient(out IKubernetes client, out string error)
{
if (_client is null)
{
client = null!;
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
? string.Empty
: $" Детали: {_clientInitializationError}";
error = "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT)." + details;
return false;
}
client = _client;
error = string.Empty;
return true;
}
protected string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
{
_logger?.LogError(exception, "Ошибка Kubernetes в tool '{ToolName}' (namespace='{Namespace}'{ResourcePart}): {ExceptionMessage}",
toolName, @namespace,
string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'",
exception.Message);
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
if (exception is global::k8s.Autorest.HttpOperationException httpException)
{
var statusCode = httpException.Response?.StatusCode;
var reason = httpException.Response?.ReasonPhrase;
var body = string.IsNullOrWhiteSpace(httpException.Response?.Content)
? "-"
: httpException.Response!.Content;
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}";
}
return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}";
}
protected string FormatException(string toolName, Exception exception, string? resource = null)
{
_logger?.LogError(exception, "Ошибка exception в tool '{ToolName}'{ResourcePart}: {ExceptionMessage}",
toolName,
string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'",
exception.Message);
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

@@ -1,20 +0,0 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace LazyBear.MCP.Services;
[McpServerToolType]
public static class TradingTools
{
[McpServerTool, Description("Получить текущую цену актива")]
public static string GetCurrentPrice([Description("Тикер актива, например BTCUSD")] string ticker)
{
return $"Цена {ticker}: 50000 USD (фейковые данные)";
}
[McpServerTool, Description("Получить информацию о позиции")]
public static string GetPositionInfo([Description("ID позиции")] string positionId)
{
return $"Позиция {positionId}: Long BTC, PnL: +500 USD";
}
}

View File

@@ -0,0 +1,738 @@
@using LazyBear.MCP.Services.Logging
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
@inject InMemoryLogSink LogSink
@inject GlobalKeyboardService KeyboardService
@inject LocalizationService Localization
@inject IHostApplicationLifetime AppLifetime
@implements IDisposable
<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 Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()"
Loc="@Localization.Current" />
}
else if (_activeTab == Tab.Logs)
{
<LogsTab Entries="@GetFilteredLogEntries()"
SelectedIndex="@_logSelection"
SelectedIndexChanged="@OnLogSelectionChanged"
SelectedFilter="@_logFilters[_logFilterIndex]"
ViewportRows="@GetLogsViewportRows()"
IsStickyToBottom="@_logsStickToBottom"
Loc="@Localization.Current" />
}
else
{
<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 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 += OnRegistryChanged;
LogSink.OnLog += OnNewLog;
KeyboardService.OnKeyPressed += OnConsoleKeyPressed;
Localization.OnChanged += OnLocaleChanged;
}
// Конвертация ConsoleKeyInfo → KeyboardEventArgs для переиспользования существующей логики
private static KeyboardEventArgs ConvertKey(ConsoleKeyInfo key)
{
var name = key.Key switch
{
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 -= OnRegistryChanged;
LogSink.OnLog -= OnNewLog;
KeyboardService.OnKeyPressed -= OnConsoleKeyPressed;
Localization.OnChanged -= OnLocaleChanged;
}
}

View File

@@ -0,0 +1,116 @@
@using LazyBear.MCP.Services.Logging
<Rows>
<Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content=" " />
<Columns>
@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=" " />
@if (Entries.Count == 0)
{
<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
{
<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 {
// Внутренние ключи фильтров — не локализуются (используются в логике App.razor)
private static readonly string[] Filters = ["All", "Info", "Warn", "Error"];
[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 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()
{
if (Entries.Count == 0)
{
return $"{Loc.FilterLabel}: {FilterDisplay(SelectedFilter)}";
}
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 string GetDetailsText()
{
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));
}
private string FormatEntry(int index)
{
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 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

@@ -0,0 +1,65 @@
<Rows>
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content=" " />
@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="@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

@@ -0,0 +1,79 @@
<Rows>
<Markup Content="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content=" " />
@if (Entries.Count == 0)
{
<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

@@ -0,0 +1,21 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
@foreach (var (tool, idx) in Module.ToolNames.Select((t, i) => (t, i)))
{
var toolEnabled = Registry.IsToolEnabled(Module.ModuleName, tool);
var toolName = tool;
var moduleName = Module.ModuleName;
var fo = StartFocusIdx + idx;
<TextButton Content="@(toolEnabled ? $"✓ {toolName}" : $"✗ {toolName}")"
OnClick="@(() => Registry.ToggleTool(moduleName, toolName))"
BackgroundColor="@(toolEnabled ? Spectre.Console.Color.DarkGreen : Spectre.Console.Color.Grey11)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@fo" />
}
@code {
[Parameter, EditorRequired] public IToolModule Module { get; set; } = null!;
[Parameter] public int StartFocusIdx { get; set; } = 100;
}

View File

@@ -0,0 +1,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

@@ -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

@@ -1,4 +1,29 @@
{
{
"Kubernetes": {
"KubeconfigPath": "",
"DefaultNamespace": "default"
},
"Jira": {
"Url": "",
"Token": "",
"Project": ""
},
"Confluence": {
"Url": "",
"Token": "",
"Username": "",
"SpaceKey": ""
},
"GitLab": {
"Url": "",
"Token": "",
"Project": ""
},
"Qdrant": {
"Url": "",
"ApiKey": "",
"DefaultCollection": "knowledge"
},
"Logging": {
"LogLevel": {
"Default": "Information",
@@ -7,4 +32,4 @@
}
},
"AllowedHosts": "*"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 153 KiB

37
LazyBearWorks.sln Normal file
View File

@@ -0,0 +1,37 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LazyBear.MCP", "LazyBear.MCP\LazyBear.MCP.csproj", "{F6E53181-377E-ED83-24E1-8161CCB14BDA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|x64.ActiveCfg = Debug|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|x64.Build.0 = Debug|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|x86.ActiveCfg = Debug|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|x86.Build.0 = Debug|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|Any CPU.Build.0 = Release|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|x64.ActiveCfg = Release|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|x64.Build.0 = Release|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|x86.ActiveCfg = Release|Any CPU
{F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FCFDDEA5-ED7B-4EBA-908D-D5EB33CF7A8A}
EndGlobalSection
EndGlobal

442
README.md
View File

@@ -1,19 +1,348 @@
# LazyBear MCP Server
.NET 10 сервер Model Context Protocol (MCP) для интеграции торговых AI-инструментов.
![LazyBear Logo](resources/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`
Сервер запустится на `http://localhost:5000`
## Примеры интеграции
### Docker
### Codex (Windows конфигурация)
```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`
@@ -28,7 +357,7 @@ dotnet run
}
```
### Continue (расширение VS Code)
### Continue (VS Code)
Файл: `.vscode/continue/config.json`
@@ -37,18 +366,14 @@ dotnet run
"mcpServers": {
"lazybear": {
"command": "dotnet",
"args": [
"run",
"--project",
"${workspaceFolder}/LazyBear.MCP"
],
"args": ["run", "--project", "${workspaceFolder}/LazyBear.MCP"],
"type": "stdio"
}
}
}
```
### OpenCode (Linux/Mac конфигурация)
### OpenCode (Linux/Mac)
Файл: `~/.opencode/.mcp.json`
@@ -63,73 +388,82 @@ dotnet run
}
```
### Использование через CLI
Тестирование через MCP inspector:
### MCP Inspector
```bash
npm install -g @modelcontextprotocol/inspector
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
```
Прямое тестирование через stdin:
---
## 🔧 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
```
## Доступные инструменты
---
### GetCurrentPrice
Получить текущую цену актива.
**Аргументы:**
- `ticker` (строка): Тикер актива (например, "BTCUSD")
**Пример:**
```json
{
"name": "GetCurrentPrice",
"arguments": {
"ticker": "BTCUSD"
}
}
```
### GetPositionInfo
Получить информацию о позиции.
**Аргументы:**
- `positionId` (строка): ID позиции
**Пример:**
```json
{
"name": "GetPositionInfo",
"arguments": {
"positionId": "POSI-001"
}
}
```
## Разработка
## 🛠️ Разработка
### Сборка
```bash
dotnet build
```
### Запуск
```bash
dotnet run
```
### Тестирование с MCP Inspector
### Тестирование
```bash
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
```
## Лицензия
---
MIT
## 📦 Stack
- **Язык:** C#
- **Framework:** .NET 10
- **Framework Web:** ASP.NET Core 9
- **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
- **Метаданные методов**: 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,26 @@
# OpenCode Question Policy
Prefer `question` over prose option lists when clarification will materially change the result.
## When To Use `question`
- Use it when there are 2+ meaningful and mutually exclusive options.
- Use it when the answer changes architecture, config, output format, naming, priorities, workflow direction, or another blocking implementation choice.
- Ask in the primary agent before delegating to a subagent if the answer must steer delegated work.
## When Not To Use `question`
- Skip it when the user already gave a specific direction.
- Skip it when only one valid answer exists.
- Skip it when the choice is cosmetic and does not affect execution.
## How To Ask
- Ask 2-4 options per question.
- Put the recommended option first.
- Keep labels short and descriptions to one sentence each.
- Combine related blocking decisions into one `question` call when possible, with at most 3 questions in the dialog.
- Do not print a prose preface like `Вот варианты:` before calling the tool.
- Do not add an `Other` or `Другое` option unless free-form input is not available; the OpenCode UI already supports custom input.
## After The Answer
- Continue the task immediately.
- Do not repeat the full option list in prose unless the user asks for a recap.
- If `question` is unavailable, ask one short plain-text question instead.

View File

@@ -0,0 +1,34 @@
# RazorConsole — Документация
Документация по библиотеке [RazorConsole](https://github.com/RazorConsole/RazorConsole) для проекта LazyBearWorks.
## Файлы
| Файл | Содержание |
|---|---|
| [overview.md](overview.md) | Обзор, установка, минимальный пример, примеры приложений |
| [components.md](components.md) | Полный справочник по 25+ встроенным компонентам |
| [custom-translators.md](custom-translators.md) | Архитектура VDOM, создание и регистрация кастомных трансляторов |
| [contributing.md](contributing.md) | Настройка окружения, стандарты кода, процесс PR |
## Быстрый старт
```bash
dotnet add package RazorConsole.Core
```
```xml
<!-- .csproj -->
<Project Sdk="Microsoft.NET.Sdk.Razor">
```
```csharp
// Program.cs
IHostBuilder hostBuilder = Host.CreateDefaultBuilder(args)
.UseRazorConsole<MyRootComponent>();
await hostBuilder.Build().RunAsync();
```
## Версия
Документация актуальна для **v0.5.0** (март 2026).

View File

@@ -0,0 +1,418 @@
# RazorConsole — Встроенные компоненты
Полный справочник по 25+ компонентам, поставляемым с `RazorConsole.Core`.
Каждый компонент транслируется в `IRenderable` Spectre.Console во время рендеринга.
> Параметры, отмеченные ⚠️, являются обязательными.
---
## Макет (Layout)
### Align
Выравнивает дочерний контент горизонтально и вертикально.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Вложенный контент |
| `Horizontal` | `HorizontalAlignment` | `Left` | `Left`, `Center`, `Right` |
| `Vertical` | `VerticalAlignment` | `Top` | `Top`, `Middle`, `Bottom` |
| `Width` | `int?` | `null` | Фиксированная ширина в символах |
| `Height` | `int?` | `null` | Фиксированная высота в строках |
---
### Columns
Располагает дочерние элементы горизонтально (колонками).
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Элементы колонок |
| `Expand` | `bool` | `false` | Растянуть на всю ширину консоли |
---
### Rows
Стекует дочерние элементы вертикально.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Элементы строк |
| `Expand` | `bool` | `false` | Растянуть на всю высоту |
---
### FlexBox
CSS-подобный flexbox-макет с настройкой направления, выравнивания и переноса.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Элементы контейнера |
| `Direction` | `FlexDirection` | `Row` | `Row` (горизонталь) или `Column` (вертикаль) |
| `Justify` | `FlexJustify` | `Start` | `Start`, `End`, `Center`, `SpaceBetween`, `SpaceAround`, `SpaceEvenly` |
| `Align` | `FlexAlign` | `Start` | `Start`, `End`, `Center`, `Stretch` |
| `Wrap` | `FlexWrap` | `NoWrap` | `NoWrap`, `Wrap` |
| `Gap` | `int` | `0` | Отступ между элементами (символы / строки) |
| `Width` | `int?` | `null` | Явное ограничение ширины |
| `Height` | `int?` | `null` | Явное ограничение высоты |
---
### Grid
Многострочный многоколоночный макет с точным управлением ячейками.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Строки и ячейки сетки |
| `Columns` | `int` | `2` | Количество колонок |
| `Expand` | `bool` | `false` | Растянуть на доступную ширину |
| `Width` | `int?` | `null` | Фиксированная ширина |
---
### Padder
Добавляет внешние отступы вокруг дочернего контента.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Внутренний контент |
| `Padding` | `Padding` | `new(0,0,0,0)` | Отступы (left, top, right, bottom) |
---
### Scrollable
Постраничный скроллинг для типизированных коллекций с поддержкой клавиатуры.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Items` | `IReadOnlyList<TItem>` | `Array.Empty<TItem>()` | Источник данных |
| `PageSize` | `int` | `1` | Элементов на странице |
| `ChildContent` | `RenderFragment<ScrollContext<TItem>>` | — | Разметка видимой страницы |
| `ScrollOffset` | `int` | `0` | Двусторонний: индекс начала страницы |
| `ScrollOffsetChanged` | `EventCallback<int>` | — | Срабатывает при смене offset |
| `IsScrollbarEmbedded` | `bool` | `true` | Встроить скроллбар внутрь `Table`/`Panel`/`Border` |
#### ScrollContext\<TItem\>
| Член | Тип | Описание |
|---|---|---|
| `this[int i]` | `TItem` | Видимый элемент по индексу |
| `Count` | `int` | Количество видимых элементов |
| `KeyDownEventHandler` | `Func<KeyboardEventArgs,Task>` | Привязать через `@onkeydown` |
| `CurrentOffset` | `int` | Текущий scroll offset |
| `PagesCount` | `int` | Всего страниц |
---
### ViewHeightScrollable
Постраничный скроллинг произвольного `RenderFragment` по строкам (без типизированных данных).
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment<ScrollContext>` | — | ⚠️ Контент вьюпорта |
| `LinesToRender` | `int` | `10` | Высота вьюпорта в строках |
| `ScrollOffset` | `int` | `0` | Двусторонний: индекс первой видимой строки |
| `ScrollOffsetChanged` | `EventCallback<int>` | — | Срабатывает при скролле |
| `Scrollbar` | `ScrollbarSettings?` | `null` | Настройки скроллбара |
| `IsScrollbarEmbedded` | `bool` | `true` | Встроить скроллбар внутрь border-компонента |
**Горячие клавиши:** `↑`/`↓` — одна строка, `PageUp`/`PageDown`/`Space` — страница, `Home`/`End` — границы.
#### ScrollbarSettings
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `TrackChar` | `char` | `'│'` | Символ дорожки |
| `ThumbChar` | `char` | `'█'` | Символ ползунка |
| `TrackColor` | `Color` | `Color.Grey` | Цвет дорожки |
| `ThumbColor` | `Color` | `Color.White` | Цвет ползунка |
| `TrackFocusedColor` | `Color` | `Color.Grey74` | Цвет дорожки при фокусе |
| `ThumbFocusedColor` | `Color` | `Color.DeepSkyBlue1` | Цвет ползунка при фокусе |
| `MinThumbHeight` | `int` | `1` | Минимальная высота ползунка |
---
## Ввод (Input)
### TextInput
Поле ввода текста с placeholder, маскированием и управлением фокусом.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Value` | `string?` | `null` | Двустороннее связывание текста |
| `ValueChanged` | `EventCallback<string?>` | — | Срабатывает при изменении |
| `OnInput` | `EventCallback<string?>` | — | Срабатывает при каждом нажатии |
| `Placeholder` | `string?` | `null` | Текст-подсказка |
| `MaskInput` | `bool` | `false` | Маскирование символов (пароли) |
| `FocusOrder` | `int?` | `null` | Порядок фокуса |
---
### TextButton
Кликабельная кнопка с изменением цвета при фокусе.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Content` | `string` | `string.Empty` | Текст кнопки |
| `BackgroundColor` | `Color` | `Color.Default` | Фон в нормальном состоянии |
| `FocusedColor` | `Color` | `Color.DeepSkyBlue1` | Фон при фокусе |
| `OnClick` | `EventCallback` | — | Срабатывает при нажатии |
| `FocusOrder` | `int?` | `null` | Порядок фокуса |
---
### Select
Интерактивный выпадающий список с клавиатурной навигацией.
**Основные параметры:**
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Options` | `string[]` | `Array.Empty<string>()` | Доступные варианты |
| `Value` | `string?` | `null` | Текущий выбор |
| `ValueChanged` | `EventCallback<string>` | — | Срабатывает при смене |
| `OnSelected` | `EventCallback<string?>` | — | Срабатывает при подтверждении |
| `OnClear` | `EventCallback` | — | Срабатывает при очистке |
| `Placeholder` | `string` | `"Select an option"` | Текст при отсутствии выбора |
| `Expand` | `bool` | `false` | Растянуть горизонтально |
| `FocusOrder` | `int?` | `null` | Порядок фокуса |
| `BorderStyle` | `BoxBorder` | `Rounded` | Стиль рамки |
**Клавиатура:** стрелки, `Space` для переключения, `Enter` для подтверждения, `Escape` для отмены, буквы для быстрого перехода.
---
## Отображение (Display)
### Markup
Стилизованный текст с тегами разметки Spectre.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Content` ⚠️ | `string` | — | Текст (автоматически экранируется) |
| `Foreground` | `Color` | `Style.Plain.Foreground` | Цвет текста |
| `Background` | `Color` | `Style.Plain.Background` | Цвет фона |
| `Decoration` | `Decoration` | `None` | `Bold`, `Italic`, `Underline` и др. |
| `link` | `string?` | `null` | Гиперссылка |
---
### Markdown
Рендеринг markdown-строки в консоль.
| Параметр | Тип | Описание |
|---|---|---|
| `Content` ⚠️ | `string` | Markdown-текст для отображения |
---
### Panel
Обёртка Spectre.Console Panel с заголовком и рамкой.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Содержимое панели |
| `Title` | `string?` | `null` | Заголовок панели |
| `TitleColor` | `Color?` | `null` | Цвет заголовка |
| `BorderColor` | `Color?` | `null` | Цвет рамки |
| `Border` | `BoxBorder?` | `null` | Стиль рамки |
| `Height` | `int?` | `null` | Фиксированная высота |
| `Width` | `int?` | `null` | Фиксированная ширина |
| `Padding` | `Padding?` | `null` | Внутренние отступы |
| `Expand` | `bool` | `false` | Растянуть на ширину |
---
### Border
Рамка вокруг дочернего контента (без заголовка).
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `ChildContent` | `RenderFragment?` | — | Контент внутри рамки |
| `BorderColor` | `Color?` | `null` | Цвет рамки |
| `BoxBorder` | `BoxBorder` | `Rounded` | Стиль рамки |
| `Padding` | `Padding` | `new(0,0,0,0)` | Внутренние отступы |
---
### Table
HTML-подобная таблица, транслируемая в Spectre.Console `Table`.
> Ключевая идея: используйте стандартные `<table>`, `<thead>`, `<tbody>`, `<tfoot>`, `<tr>`, `<th>`, `<td>`.
| Атрибут | Тип | По умолчанию | Описание |
|---|---|---|---|
| `class="table"` ⚠️ | — | — | Обязательный хук для транслятора |
| `data-expand` | `bool` | `false` | Растянуть на ширину консоли |
| `data-width` | `int?` | `null` | Фиксированная ширина |
| `data-title` | `string?` | `null` | Подпись таблицы |
| `data-border` | `TableBorderStyle` | `None` | Стиль рамки |
| `data-show-headers` | `bool` | `true` | Показывать заголовки |
Выравнивание колонок: атрибут `data-align="left|center|right"` на `<th>`.
```razor
<table class="table" data-border="Square" data-expand="true" data-title="Build status">
<thead>
<tr>
<th data-align="left">Stage</th>
<th data-align="right">Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>Compile</td>
<td><Markup Content="[green]✔[/]" /></td>
</tr>
</tbody>
</table>
```
---
### Figlet
Большой ASCII-арт текст с использованием FIGlet-шрифтов.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Content` | `string` | `string.Empty` | Текст для рендеринга |
| `Justify` | `Justify` | `Center` | Выравнивание |
| `Color` | `Color` | `Color.Default` | Цвет глифов |
---
### BarChart
Горизонтальная барная диаграмма.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `BarChartItems` ⚠️ | `List<IBarChartItem>` | — | Данные (`Label`, `Value`, `Color`) |
| `Width` | `int?` | `null` | Ширина (по умолчанию — вся консоль) |
| `Label` | `string?` | `null` | Заголовок диаграммы |
| `MaxValue` | `double?` | `null` | Максимальное значение шкалы |
| `ShowValues` | `bool` | `false` | Показывать числа рядом с барами |
| `Culture` | `CultureInfo` | текущая | Культура для форматирования чисел |
---
### BreakdownChart
Диаграмма разбивки (pie-style).
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `BreakdownChartItems` ⚠️ | `List<IBreakdownChartItem>` | — | Данные (`Label`, `Value`, `Color`) |
| `Compact` | `bool` | `false` | Компактный режим |
| `Expand` | `bool` | `false` | Растянуть на всю ширину |
| `Width` | `int?` | `null` | Ширина |
| `ShowTags` | `bool` | `false` | Показывать легенду |
| `ShowTagValues` | `bool` | `false` | Показывать абсолютные значения |
| `ShowTagValuesPercentage` | `bool` | `false` | Показывать проценты |
---
### StepChart
Ступенчатая диаграмма с использованием Unicode box-drawing символов.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Series` ⚠️ | `List<ChartSeries>` | — | Набор серий данных |
| `Width` | `int` | `60` | Ширина диаграммы |
| `Height` | `int` | `20` | Высота диаграммы |
| `ShowAxes` | `bool` | `true` | Показывать оси |
| `AxesColor` | `Color` | `Color.Grey` | Цвет осей |
| `LabelsColor` | `Color` | `Color.Grey` | Цвет подписей |
| `Title` | `string?` | `null` | Заголовок |
| `TitleColor` | `Color` | `Color.Grey` | Цвет заголовка |
**ChartSeries:**
| Член | Тип | Описание |
|---|---|---|
| `Color` | `Color` | Цвет линии |
| `Points` | `List<(double X, double Y)>` | Точки данных |
---
### SpectreCanvas
Рендеринг массива пикселей с разными цветами.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Pixels` ⚠️ | `(int x, int y, Color color)[]` | — | Массив пикселей |
| `CanvasWidth` ⚠️ | `int` | — | Ширина холста |
| `CanvasHeight` ⚠️ | `int` | — | Высота холста |
| `MaxWidth` | `int?` | `null` | Максимальная ширина |
| `PixelWidth` | `int` | `2` | Ширина прямоугольника пикселя |
| `Scale` | `bool` | `false` | Масштабировать при рендеринге |
---
### SyntaxHighlighter
Подсветка синтаксиса кода с темами ColorCode.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `Code` | `string` | `string.Empty` | Исходный код |
| `Language` | `string?` | `null` | Язык: `"csharp"`, `"json"`, `"sql"` и др. |
| `Theme` | `string?` | `null` | Тема оформления |
| `ShowLineNumbers` | `bool` | `false` | Показывать номера строк |
**Встроенные языки:** `text/plaintext/plain`, `csharp/cs`, `razor`, `html`, `json`, `xml`, `sql`, `js/javascript`, `ts/typescript`, `css`, `powershell/ps`, `python`, `md/markdown`.
---
### ModalWindow
Модальное окно с автоматическим центрированием через z-index.
---
### ModalWindow
Модальное окно поверх текущего экрана с автоматическим центрированием.
> Визуализация построена на z-index, чтобы окно корректно перекрывало другие элементы.
---
## Утилиты
### Spinner
Анимированный индикатор прогресса.
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
| `SpinnerType` | `Spinner` | `Spinner.Known.Dots` | Тип спиннера |
| `SpinnerName` | `string?` | `null` | Имя спиннера (переопределение) |
| `Message` | `string?` | `null` | Сопроводительный текст |
| `Style` | `string?` | `null` | Markup-стиль Spectre |
| `AutoDismiss` | `bool` | `false` | Скрыть по завершении |
---
### Newline
Вставляет один перенос строки. Параметров нет.

View File

@@ -0,0 +1,69 @@
# RazorConsole — Контрибьюция
## Начало работы
- **Для крупных PR, рефакторингов, новых фич** — сначала создай Issue для обсуждения
- **Discord:** https://discord.gg/DphHAnJxCM — мейнтейнеры активны там
## Настройка окружения разработки
### Требования
- .NET 8.0 или 9.0 SDK
- Git LFS (для крупных медиафайлов)
### Клонирование
```bash
git lfs install
git clone https://github.com/RazorConsole/RazorConsole.git
cd RazorConsole
dotnet build RazorConsole.slnx
dotnet test RazorConsole.slnx
```
## Стандарты кода
- Следовать правилам `.editorconfig` (4 пробела, file-scoped namespaces, System usings первые)
- Предпочитать `async/await` с `ConfigureAwait(false)` в библиотечном коде
- Public API: nullable-enabled, документировать исключения и edge cases
- Spectre.Console renderables — immutable вне rendering loops
## Перед отправкой PR
```bash
dotnet format RazorConsole.slnx # форматирование
dotnet test RazorConsole.slnx # тесты (должны пройти на Linux и Windows)
```
При изменениях в focus/keyboard handling — добавить/обновить тесты в `FocusManagerTests` или `KeyboardEventManagerTests`.
## Процесс PR
1. Создать Issue для крупных изменений
2. Форкнуть репозиторий, создать ветку от `main`
3. Сделать изменения, соблюдая стандарты кода
4. Написать/обновить тесты
5. Запустить `dotnet format` и `dotnet test`
6. Отправить PR с описанием изменений
## Структура тестов
- Тесты находятся в `src/RazorConsole.Tests`
- CI требует чистого прохождения на Linux и Windows
- Покрытие: Codecov (Cobertura format)
## Документация
- `README.md` — обновлять при user-facing изменениях
- XML doc-комментарии для public API
- `examples/` — добавлять примеры для новых фич
- `design-doc/` — архитектурные заметки
## Git LFS
Отслеживаемые типы файлов: `*.gif`, `*.png`, `*.jpg`, `*.jpeg`, `*.mp4`, `*.mov`, `*.avi`, `*.zip`, `*.tar.gz`, `*.pdf`.
## Релизный процесс
Создание GitHub Release запускает `.github/workflows/release.yml` — сборка, тесты, паковка и публикация. Версии следуют семантическому версионированию.

View File

@@ -0,0 +1,276 @@
# RazorConsole — Кастомные VDOM-трансляторы
## Архитектура
RazorConsole использует Virtual DOM (VDOM) для преобразования Razor-компонентов в Spectre.Console `IRenderable`. Система трансляторов (translators) является расширяемой: можно добавить поддержку новых Spectre.Console-конструкций или построить полностью кастомные компоненты.
### Ключевые компоненты
#### `IVdomElementTranslator`
```csharp
public interface IVdomElementTranslator
{
// Чем меньше значение — тем выше приоритет (обрабатывается раньше).
int Priority { get; }
bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable);
}
```
#### `TranslationContext`
```csharp
public sealed class TranslationContext
{
// Рекурсивный перевод дочерних узлов
public bool TryTranslate(VNode node, out IRenderable? renderable);
}
```
#### `VdomSpectreTranslator`
Оркестратор, который:
1. Получает список трансляторов через DI (отсортированных по приоритету)
2. Пробует каждый по очереди
3. Возвращает первый успешный результат
4. Предоставляет статические вспомогательные методы
### Pipeline трансляции
```
Razor-компонент → ConsoleRenderer → VNode tree → VdomSpectreTranslator
→ [Priority 10] → [Priority 20] → ... → [Priority 1000 (fallback)]
→ IRenderable
```
---
## Встроенные трансляторы
| Приоритет | Транслятор | Обрабатывает |
|---|---|---|
| 10 | TextElementTranslator | `<span data-text="true">` |
| 20 | HtmlInlineTextElementTranslator | `<strong>`, `<em>`, `<code>` |
| 30 | ParagraphElementTranslator | `<p>` |
| 40 | SpacerElementTranslator | `<div data-spacer="true">` |
| 50 | NewlineElementTranslator | `<br>` |
| 60 | SpinnerElementTranslator | `<div class="spinner">` |
| 7080 | Button translators | `<button>` |
| 90 | SyntaxHighlighterElementTranslator | `<div class="syntax-highlighter">` |
| 100190 | Layout translators | Panels, Rows, Columns, Grid, Padding, Align |
| 1000 | FailToRenderElementTranslator | Fallback для необработанных узлов |
---
## Создание кастомного транслятора
### Шаг 1: Реализовать интерфейс
```csharp
using RazorConsole.Core.Rendering.Vdom;
using RazorConsole.Core.Vdom;
using Spectre.Console;
using Spectre.Console.Rendering;
public sealed class OverflowElementTranslator : IVdomElementTranslator
{
public int Priority => 85; // между встроенными 80 и 90
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
// 1. Быстрые проверки — fail fast
if (node.Kind != VNodeKind.Element) return false;
if (!string.Equals(node.TagName, "div", StringComparison.OrdinalIgnoreCase)) return false;
if (!node.Attributes.TryGetValue("data-overflow", out var overflowType)) return false;
// 2. Трансляция дочерних узлов
if (!VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out var children)) return false;
var content = VdomSpectreTranslator.ComposeChildContent(children);
// 3. Создать IRenderable
renderable = overflowType?.ToLowerInvariant() switch
{
"ellipsis" => new Padder(content).Overflow(Overflow.Ellipsis),
"crop" => new Padder(content).Overflow(Overflow.Crop),
"fold" => new Padder(content).Overflow(Overflow.Fold),
_ => content
};
return true;
}
}
```
### Шаг 2: Зарегистрировать транслятор
```csharp
using Microsoft.Extensions.Hosting;
using RazorConsole.Core;
using RazorConsole.Core.Vdom;
IHostBuilder hostBuilder = Host.CreateDefaultBuilder(args)
.UseRazorConsole<MyComponent>(configure: config =>
{
config.ConfigureServices(services =>
{
// По типу (создаётся через DI)
services.AddVdomTranslator<OverflowElementTranslator>();
// По экземпляру
services.AddVdomTranslator(new OverflowElementTranslator());
// Через фабрику (для инъекции зависимостей)
services.AddVdomTranslator(sp =>
new OverflowElementTranslator(sp.GetRequiredService<IMyService>()));
});
});
```
### Шаг 3: Использовать в Razor-компонентах
```razor
<div data-overflow="ellipsis">
Очень длинный текст, который будет обрезан с многоточием...
</div>
```
---
## Вспомогательные методы `VdomSpectreTranslator`
### Инспекция узла
```csharp
string? value = VdomSpectreTranslator.GetAttribute(node, "data-style");
bool hasClass = VdomSpectreTranslator.HasClass(node, "my-class");
string? text = VdomSpectreTranslator.CollectInnerText(node);
```
### Парсинг атрибутов
```csharp
// bool
if (VdomSpectreTranslator.TryGetBoolAttribute(node, "data-enabled", out bool enabled)) { }
// int с fallback
int count = VdomSpectreTranslator.TryGetIntAttribute(node, "data-count", fallback: 10);
// положительный int
if (VdomSpectreTranslator.TryParsePositiveInt(rawValue, out int result)) { }
// int? (null если невалидно)
int? width = VdomSpectreTranslator.ParseOptionalPositiveInt(rawValue);
// CSS-подобный padding: "1", "1,2", "1,2,3,4"
if (VdomSpectreTranslator.TryParsePadding(rawValue, out Padding padding)) { }
```
### Парсинг выравнивания
```csharp
var hAlign = VdomSpectreTranslator.ParseHorizontalAlignment(value); // Left/Center/Right
var vAlign = VdomSpectreTranslator.ParseVerticalAlignment(value); // Top/Middle/Bottom
```
### Работа с дочерними узлами
```csharp
// Перевести дочерние узлы
if (VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out List<IRenderable> renderables)) { }
// Объединить в один IRenderable
// - 1 элемент → возвращает его напрямую
// - N элементов → Rows
// - 0 элементов → пустой Markup
IRenderable composed = VdomSpectreTranslator.ComposeChildContent(renderables);
```
---
## Правила выбора приоритета
| Диапазон | Когда использовать |
|---|---|
| 19 | Переопределить встроенное поведение |
| 10190 | Вклиниться между конкретными встроенными трансляторами |
| 200999 | Общие кастомные трансляторы |
| 1000+ | Fallback-обработчики |
---
## Лучшие практики
1. **Fail fast** — сразу возвращай `false` если узел не совпадает
2. **Case-insensitive сравнение**`StringComparison.OrdinalIgnoreCase` для `TagName` и атрибутов
3. **Всегда используй `TryConvertChildrenToRenderables`** для рекурсивной трансляции детей
4. **Валидируй атрибуты** — предусматривай defaults, не бросай исключения
5. **Immutability** — создавай новые экземпляры `IRenderable`, не мутируй существующие
6. **Thread safety** — трансляторы должны быть stateless или использовать immutable state
---
## Продвинутые сценарии
### DI в трансляторе
```csharp
public sealed class DatabaseStyleTranslator : IVdomElementTranslator
{
private readonly IStyleProvider _styleProvider;
public DatabaseStyleTranslator(IStyleProvider styleProvider)
{
_styleProvider = styleProvider;
}
public int Priority => 95;
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
if (!node.Attributes.TryGetValue("data-style-id", out var styleId)) return false;
var style = _styleProvider.GetStyle(styleId);
// ... создать renderable ...
return true;
}
}
// Регистрация
services.AddSingleton<IStyleProvider, MyStyleProvider>();
services.AddVdomTranslator<DatabaseStyleTranslator>();
```
### Условная трансляция (Alert-компонент)
```csharp
public sealed class AlertTranslator : IVdomElementTranslator
{
public int Priority => 105;
public bool TryTranslate(VNode node, TranslationContext context, out IRenderable? renderable)
{
renderable = null;
if (!VdomSpectreTranslator.HasClass(node, "alert")) return false;
if (!VdomSpectreTranslator.TryConvertChildrenToRenderables(
node.Children, context, out var children)) return false;
var content = VdomSpectreTranslator.ComposeChildContent(children);
renderable = VdomSpectreTranslator.HasClass(node, "alert-danger")
? new Panel(content).BorderColor(Color.Red).Header("[red]⚠ Error[/]")
: VdomSpectreTranslator.HasClass(node, "alert-success")
? new Panel(content).BorderColor(Color.Green).Header("[green]✓ Success[/]")
: new Panel(content).BorderColor(Color.Blue).Header("[blue] Info[/]");
return true;
}
}
```

View File

@@ -0,0 +1,116 @@
# RazorConsole — Обзор
**Репозиторий:** https://github.com/RazorConsole/RazorConsole
**Лицензия:** MIT
**Последняя версия:** v0.5.0 (март 2026)
**NuGet:** `RazorConsole.Core`
## Что такое RazorConsole?
RazorConsole — это .NET-библиотека для построения интерактивных TUI-приложений (Terminal User Interface) с использованием синтаксиса Razor-компонентов и движка рендеринга Spectre.Console.
Библиотека заполняет разрыв между веб-разработкой на Blazor/Razor и консольными приложениями: разработчик пишет компоненты `.razor`, а на выходе получает полноценный интерактивный терминальный интерфейс.
## Ключевые возможности
| Возможность | Описание |
|---|---|
| Компонентная архитектура | Razor-компоненты с data binding, event handling и lifecycle |
| 25+ встроенных компонентов | Макет, ввод, отображение, утилиты |
| Интерактивность | Кнопки, текстовые поля, селекторы, навигация клавиатурой |
| Hot Reload | Обновление UI без перезапуска через metadata update handler |
| VDOM + Translators | Виртуальный DOM с расширяемой системой трансляторов |
| DI интеграция | Построен на `Microsoft.Extensions.Hosting` |
| Галерея компонентов | Глобальный инструмент `razorconsole-gallery` |
## Технологический стек
- **Runtime:** .NET 8 / .NET 9
- **SDK:** `Microsoft.NET.Sdk.Razor` (обязателен в `.csproj`)
- **Рендеринг:** Spectre.Console
- **DI/Host:** `Microsoft.Extensions.Hosting`
- **Синтаксис подсветки:** ColorCode
## Установка
```bash
dotnet add package RazorConsole.Core
```
## Минимальный проект
### Файл проекта (`.csproj`)
```xml
<Project Sdk="Microsoft.NET.Sdk.Razor">
<!-- другие настройки -->
</Project>
```
### Компонент `Counter.razor`
```razor
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using RazorConsole.Components
<Columns>
<p>Current count</p>
<Markup Content="@currentCount.ToString()" Foreground="@Spectre.Console.Color.Green" />
</Columns>
<TextButton Content="Click me"
OnClick="IncrementCount"
BackgroundColor="@Spectre.Console.Color.Grey"
FocusedColor="@Spectre.Console.Color.Blue" />
@code {
private int currentCount = 0;
private void IncrementCount() { currentCount++; }
}
```
### `Program.cs`
```csharp
using Microsoft.Extensions.Hosting;
using RazorConsole.Core;
IHostBuilder hostBuilder = Host.CreateDefaultBuilder(args)
.UseRazorConsole<Counter>();
IHost host = hostBuilder.Build();
await host.RunAsync();
```
## Примеры приложений
| Пример | Демонстрирует |
|---|---|
| `examples/Counter/` | Основы: кнопки, состояние, layout, styled text |
| `examples/LLMAgentTUI/` | Интеграция с AI SDK (OpenAI/Ollama), чат-интерфейс |
| `examples/LoginForm/` | Форма с валидацией, маскирование пароля, состояния ошибок |
## Компонентная галерея
```bash
dotnet tool install --global RazorConsole.Gallery --version 0.0.3-alpha
razorconsole-gallery
```
## Структура репозитория
```
src/ — исходный код библиотеки
examples/ — примеры приложений
design-doc/ — архитектурные документы
nuget/ — конфигурация NuGet
release-notes/ — история релизов
docfx/ — генерация документации
website/ — сайт проекта
```
## Сообщество
- Discord: https://discord.gg/DphHAnJxCM
- Issues: https://github.com/RazorConsole/RazorConsole/issues
- Codecov: https://codecov.io/gh/RazorConsole/RazorConsole

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 и важные примечания о разработке. Обновлять при введении новых технологий или зависимостей.*

12
opencode.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "ollama/qwen3.5-agent",
"small_model": "ollama/qwen3.5-agent",
"instructions": [
"AGENTS.md",
"docs/opencode/question-policy.md"
],
"permission": {
"question": "allow"
}
}

BIN
resources/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

BIN
resources/icon_v2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
resources/icon_v3.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
resources/icon_v3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
resources/logo_v2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -0,0 +1,37 @@
# setup-ollama-agents.ps1
param(
[int]$QwenCtx = 32768,
[int]$GemmaCtx = 16384
)
function Create-OllamaModel {
param(
[string]$Base,
[string]$Name,
[int]$NumCtx,
[string]$ModelfilePath
)
Write-Host "=== Создаём $Name (num_ctx=$NumCtx) ===" -ForegroundColor Cyan
"FROM $Base`nPARAMETER num_ctx $NumCtx" | Out-File -Encoding utf8 $ModelfilePath
ollama create $Name -f $ModelfilePath
if ($LASTEXITCODE -eq 0) {
Write-Host "OK: $Name создан" -ForegroundColor Green
} else {
Write-Host "FAIL: $Name не создан" -ForegroundColor Red
}
Remove-Item $ModelfilePath -ErrorAction SilentlyContinue
}
Create-OllamaModel -Base "qwen3.5" -Name "qwen3.5-agent" -NumCtx $QwenCtx -ModelfilePath "Modelfile.qwen"
Create-OllamaModel -Base "gemma4" -Name "gemma4-agent" -NumCtx $GemmaCtx -ModelfilePath "Modelfile.gemma"
Write-Host ""
Write-Host "Проверка:" -ForegroundColor Yellow
ollama list | Select-String "agent"