Compare commits
38 Commits
7a170d137d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c5a99b8a2 | |||
| 4f78606b2c | |||
| b5fe2623b3 | |||
| e96bab114e | |||
| d12e9873f0 | |||
| 7224a423fa | |||
| 454d2a2f40 | |||
| a7e912cac7 | |||
| 01565b32d9 | |||
| 4819fbca6c | |||
| 9b27cd7dc2 | |||
| 4bf267d681 | |||
| 380768b110 | |||
| 879becadfe | |||
| c117d928b0 | |||
| e37ab09fc5 | |||
| 15f29e3e6d | |||
| e008115ced | |||
| 8ac5ad2bac | |||
| 2fe64d0903 | |||
| 2fc419b490 | |||
| 2bab8e42fa | |||
| 01d75adef1 | |||
| f1392045a6 | |||
| aa124b98af | |||
| 1c7368de5b | |||
| ebaad75087 | |||
| 0290cb4102 | |||
| b5eb33272a | |||
| 87fb9e8df8 | |||
| c8b7395ba8 | |||
| 62f8dd49b6 | |||
| 3828df69da | |||
| 8962c916e8 | |||
| 39d637f99e | |||
| 6a8d294c74 | |||
| 38f15b894e | |||
| e87f3ef5cb |
67
.clinerules
Normal 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
@@ -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
@@ -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/
|
||||||
4
.idea/.idea.LazyBearWorks/.idea/encodings.xml
generated
Normal 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>
|
||||||
61
AGENT.tui.md
Normal 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`.
|
||||||
131
AGENTS.md
@@ -1,87 +1,56 @@
|
|||||||
# AGENTS.md — LazyBear MCP
|
## AGENTS.md
|
||||||
STACK: .NET 10 | ASP.NET Core | ModelContextProtocol.AspNetCore 1.2.0
|
|
||||||
STRUCTURE: LazyBear.MCP/{Program.cs, Services/Kubernetes/{K8sConfigTools,K8sDeploymentTools,K8sNetworkTools,K8sPodsTools}, appsettings.json}
|
|
||||||
|
|
||||||
---
|
### Scope & Source of Truth
|
||||||
|
- Work in `/`.
|
||||||
|
- Trust code and project config over `README.md`.
|
||||||
|
- Primary source of truth: `LazyBear.MCP/Program.cs`.
|
||||||
|
|
||||||
## [A] КОД — ALWAYS
|
### 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.
|
||||||
|
|
||||||
PRE_EDIT: read_file → keep_style → min_diff → no_secrets_in_code
|
### Commands
|
||||||
PRE_COMMIT: dotnet build OK | MCP protocol intact | lang=ru (comments/docs/commits)
|
- Build: `dotnet build`
|
||||||
PRIORITY: user > this_file > code_style
|
- 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.
|
||||||
|
|
||||||
## [B] КОММУНИКАЦИЯ — ALWAYS
|
### 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.
|
||||||
|
|
||||||
LANG: thinking=en | output=ru | code+comments+commits=ru
|
### Documentation
|
||||||
DO: act_first → confirm_if_needed | short_and_precise | sequential_questions
|
- **TUI work:** read `AGENT.tui.md` first — keyboard, localization, RazorConsole gotchas, component contract.
|
||||||
DONT: end_with_question_only | print_secret_values | commit_.env.local
|
- RazorConsole gotchas and session notes: `docs/tui_log.md`.
|
||||||
SECRETS: use .env.local if exists; else state_once + ref .env.example
|
- RazorConsole library docs: `docs/razorconsole/` (`overview.md`, `components.md`, `custom-translators.md`).
|
||||||
LINKS: internal=relative_path spaces→%20 | external=markdown_label (not bare URL)
|
- OpenCode question policy: `docs/opencode/question-policy.md`.
|
||||||
RULES_EDIT: minimal + non_duplicative
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [C] MEMORY LOG — ALWAYS
|
|
||||||
|
|
||||||
PREFIX: lazybear/
|
|
||||||
KEY: lazybear/<type>/<n> (ex: lazybear/bug/auth-fail)
|
|
||||||
TYPES: architecture | mcp_tool | decision | bug | config | task_log
|
|
||||||
ONE_TYPE_PER_ENTITY: true
|
|
||||||
|
|
||||||
ON session_start → read_graph OR search_nodes(<topic>)
|
|
||||||
ON significant_change → create_entities OR add_observations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## [D] INTERACTION — только при вопросе пользователю
|
|
||||||
|
|
||||||
### Формат вопроса
|
|
||||||
|
|
||||||
```
|
|
||||||
<инструкция ≤15 слов>
|
|
||||||
|
|
||||||
1) <вариант ≤5 слов>
|
|
||||||
2) <вариант>
|
|
||||||
```
|
|
||||||
|
|
||||||
RULES: первый_символ=инструкция | max_options=7 | blank_line_before_options
|
|
||||||
FORBIDDEN: текст до/после блока | метки ("Выберите:") | пояснения
|
|
||||||
|
|
||||||
### Ввод — принимать любой
|
|
||||||
|
|
||||||
Принимать как валидный:
|
|
||||||
- N или N,N → выбор по номеру
|
|
||||||
- любой текст → интерпретировать по смыслу и продолжать выполнение
|
|
||||||
- abort|cancel → остановиться
|
|
||||||
|
|
||||||
НЕ блокироваться на невалидном вводе — интерпретировать намерение и продолжать.
|
|
||||||
|
|
||||||
### После получения ответа
|
|
||||||
|
|
||||||
EXECUTE: silent
|
|
||||||
NEXT_OUTPUT: result XOR next_question_block
|
|
||||||
|
|
||||||
```
|
|
||||||
✗ "Перехожу на ветку, затем подготовлю версию..."
|
|
||||||
✓ [молча] → результат или следующий вопрос
|
|
||||||
```
|
|
||||||
|
|
||||||
### Запрещённые шаблоны
|
|
||||||
|
|
||||||
```
|
|
||||||
✗ "Если хочешь, могу сделать коммит"
|
|
||||||
✗ "Дай знать если продолжить"
|
|
||||||
✗ вопросы в конце ответа без блока выбора
|
|
||||||
```
|
|
||||||
|
|
||||||
RULE: action available → always show choice block:
|
|
||||||
```
|
|
||||||
Выполнить коммит и push?
|
|
||||||
|
|
||||||
1) Да
|
|
||||||
2) Нет
|
|
||||||
```
|
|
||||||
|
|
||||||
OPTIONS: exhaustive | no "Другое" | freetext implicit (не предлагать как вариант)
|
|
||||||
|
|||||||
85
CLAUDE.md
Normal 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 |
|
||||||
16
LazyBear.MCP/.gitignore
vendored
@@ -1,16 +0,0 @@
|
|||||||
# ASP.NET Core
|
|
||||||
bin/
|
|
||||||
obj/
|
|
||||||
*.user
|
|
||||||
*.suo
|
|
||||||
*.cache
|
|
||||||
|
|
||||||
# Visual Studio
|
|
||||||
.vs/
|
|
||||||
|
|
||||||
# Rider
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Microsoft
|
|
||||||
PublishFiles/
|
|
||||||
temp
|
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseAppHost>false</UseAppHost>
|
||||||
|
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
|
||||||
|
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
|
||||||
|
<ApplicationIcon>wwwroot\favicon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -11,6 +15,8 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" 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="RestSharp" Version="112.0.0" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,18 +1,98 @@
|
|||||||
|
using LazyBear.MCP.Services.Confluence;
|
||||||
|
using LazyBear.MCP.Services.GitLab;
|
||||||
using LazyBear.MCP.Services.Jira;
|
using LazyBear.MCP.Services.Jira;
|
||||||
using LazyBear.MCP.Services.Kubernetes;
|
using LazyBear.MCP.Services.Kubernetes;
|
||||||
using ModelContextProtocol.Server;
|
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.AddSingleton<K8sClientProvider>();
|
var host = Host.CreateDefaultBuilder(args)
|
||||||
builder.Services.AddSingleton<JiraClientProvider>();
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton(logSink);
|
||||||
|
services.AddSingleton<ToolRegistryService>();
|
||||||
|
|
||||||
builder.Services.AddMcpServer()
|
// MCP-провайдеры
|
||||||
.WithHttpTransport()
|
services.AddSingleton<K8sClientProvider>();
|
||||||
.WithToolsFromAssembly();
|
services.AddSingleton<JiraClientProvider>();
|
||||||
|
services.AddSingleton<ConfluenceClientProvider>();
|
||||||
|
services.AddSingleton<GitLabClientProvider>();
|
||||||
|
services.AddSingleton<QdrantClientProvider>();
|
||||||
|
|
||||||
var app = builder.Build();
|
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
|
||||||
|
services.AddSingleton<IToolModule, JiraToolModule>();
|
||||||
|
services.AddSingleton<IToolModule, KubernetesToolModule>();
|
||||||
|
services.AddSingleton<IToolModule, ConfluenceToolModule>();
|
||||||
|
services.AddSingleton<IToolModule, GitLabToolModule>();
|
||||||
|
services.AddSingleton<IToolModule, QdrantToolModule>();
|
||||||
|
|
||||||
app.MapMcp();
|
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
|
||||||
|
services.AddHostedService<McpWebHostedService>();
|
||||||
|
|
||||||
app.Run("http://localhost:5000");
|
// Глобальный читатель клавиш — единственный источник клавишных событий для 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>())
|
||||||
|
{
|
||||||
|
registry.RegisterModule(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
"http": {
|
"http": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": false,
|
||||||
"applicationUrl": "http://localhost:5079",
|
"applicationUrl": "http://localhost:5079",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||||
|
"ASPNETCORE_URLS": "http://localhost:5152"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
469
LazyBear.MCP/README.md
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
# LazyBear MCP Server
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**.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*
|
||||||
25
LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
428
LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
19
LazyBear.MCP/Services/Confluence/ConfluenceToolModule.cs
Normal 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"
|
||||||
|
];
|
||||||
|
}
|
||||||
75
LazyBear.MCP/Services/GitLab/GitLabApiClient.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
358
LazyBear.MCP/Services/GitLab/GitLabBranchTools.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
LazyBear.MCP/Services/GitLab/GitLabClientFactory.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
LazyBear.MCP/Services/GitLab/GitLabClientProvider.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
753
LazyBear.MCP/Services/GitLab/GitLabIssueTools.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
607
LazyBear.MCP/Services/GitLab/GitLabMergeRequestTools.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
LazyBear.MCP/Services/GitLab/GitLabRepositoryTools.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
51
LazyBear.MCP/Services/GitLab/GitLabToolModule.cs
Normal 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"
|
||||||
|
];
|
||||||
|
}
|
||||||
169
LazyBear.MCP/Services/GitLab/GitLabToolsBase.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
269
LazyBear.MCP/Services/GitLab/GitLabVersionTools.cs
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ namespace LazyBear.MCP.Services.Jira;
|
|||||||
|
|
||||||
public static class JiraClientFactory
|
public static class JiraClientFactory
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan[] BackoffDurations = {
|
||||||
|
TimeSpan.FromMilliseconds(1000),
|
||||||
|
TimeSpan.FromMilliseconds(2000),
|
||||||
|
TimeSpan.FromMilliseconds(4000)
|
||||||
|
};
|
||||||
|
|
||||||
public static RestClient CreateClient(IConfiguration configuration)
|
public static RestClient CreateClient(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
var jiraUrl = configuration["Jira:Url"] ?? "";
|
var jiraUrl = configuration["Jira:Url"] ?? "";
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using LazyBear.MCP.Services.ToolRegistry;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using RestSharp;
|
using RestSharp;
|
||||||
|
|
||||||
namespace LazyBear.MCP.Services.Jira;
|
namespace LazyBear.MCP.Services.Jira;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration configuration)
|
public sealed class JiraIssueTools(
|
||||||
|
JiraClientProvider provider,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ToolRegistryService registry)
|
||||||
{
|
{
|
||||||
private readonly RestClient? _client = provider.Client;
|
private readonly RestClient? _client = provider.Client;
|
||||||
private readonly string? _clientInitializationError = provider.InitializationError;
|
private readonly string? _clientInitializationError = provider.InitializationError;
|
||||||
private readonly string _token = configuration["Jira:Token"] ?? string.Empty;
|
private readonly string _token = configuration["Jira:Token"] ?? string.Empty;
|
||||||
private readonly string _defaultProject = configuration["Jira:Project"] ?? 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 по ключу")]
|
[McpServerTool, Description("Получить задачу Jira по ключу")]
|
||||||
public async Task<string> GetIssue(
|
public async Task<string> GetIssue(
|
||||||
[Description("Ключ задачи, например PROJ-123")] string issueKey,
|
[Description("Ключ задачи, например PROJ-123")] string issueKey,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetIssue", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(issueKey))
|
if (string.IsNullOrWhiteSpace(issueKey))
|
||||||
{
|
{
|
||||||
return "Ключ задачи Jira не задан.";
|
return "Ключ задачи Jira не задан.";
|
||||||
@@ -60,6 +79,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
|||||||
[Description("Максимум задач в ответе")] int maxResults = 20,
|
[Description("Максимум задач в ответе")] int maxResults = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListIssues", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (!TryGetClient(out var client, out var error))
|
if (!TryGetClient(out var client, out var error))
|
||||||
{
|
{
|
||||||
return error;
|
return error;
|
||||||
@@ -118,6 +139,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
|||||||
[Description("Описание задачи")] string? description = null,
|
[Description("Описание задачи")] string? description = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("CreateIssue", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (!TryGetClient(out var client, out var error))
|
if (!TryGetClient(out var client, out var error))
|
||||||
{
|
{
|
||||||
return error;
|
return error;
|
||||||
@@ -190,6 +213,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
|||||||
[Description("Новое описание")] string? description = null,
|
[Description("Новое описание")] string? description = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("UpdateIssue", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description))
|
if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description))
|
||||||
{
|
{
|
||||||
return $"Нет полей для обновления задачи '{issueKey}'.";
|
return $"Нет полей для обновления задачи '{issueKey}'.";
|
||||||
@@ -256,6 +281,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
|||||||
[Description("Ключ задачи")] string issueKey,
|
[Description("Ключ задачи")] string issueKey,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetIssueStatuses", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (!TryGetClient(out var client, out var error))
|
if (!TryGetClient(out var client, out var error))
|
||||||
{
|
{
|
||||||
return error;
|
return error;
|
||||||
@@ -301,6 +328,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
|||||||
[Description("Максимум комментариев")] int limit = 20,
|
[Description("Максимум комментариев")] int limit = 20,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListIssueComments", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (!TryGetClient(out var client, out var error))
|
if (!TryGetClient(out var client, out var error))
|
||||||
{
|
{
|
||||||
return error;
|
return error;
|
||||||
@@ -349,6 +378,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
|||||||
[Description("Текст комментария")] string body,
|
[Description("Текст комментария")] string body,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("AddComment", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(body))
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
{
|
{
|
||||||
return "Текст комментария Jira не задан.";
|
return "Текст комментария Jira не задан.";
|
||||||
|
|||||||
20
LazyBear.MCP/Services/Jira/JiraToolModule.cs
Normal 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"
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using k8s;
|
using k8s;
|
||||||
using k8s.Autorest;
|
using k8s.Autorest;
|
||||||
|
using LazyBear.MCP.Services.ToolRegistry;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
namespace LazyBear.MCP.Services.Kubernetes;
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
public sealed class K8sConfigTools(
|
||||||
|
K8sClientProvider clientProvider,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ToolRegistryService registry,
|
||||||
|
ILogger<K8sConfigTools>? logger = null)
|
||||||
|
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||||
{
|
{
|
||||||
private readonly IKubernetes? _client = clientProvider.Client;
|
private const int MaxSecretKeyLimit = 100;
|
||||||
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
|
||||||
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
|
||||||
|
|
||||||
[McpServerTool, Description("Список ConfigMap в namespace")]
|
[McpServerTool, Description("Список ConfigMap в namespace")]
|
||||||
public async Task<string> ListConfigMaps(
|
public async Task<string> ListConfigMaps(
|
||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListConfigMaps", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -53,6 +60,10 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetConfigMapData", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateResourceName(name, nameof(name));
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -82,6 +93,9 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListSecrets", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -118,6 +132,10 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetSecretKeys", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateResourceName(name, nameof(name));
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -157,49 +175,4 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
|||||||
return FormatError("get_secret_keys", ns, ex, name);
|
return FormatError("get_secret_keys", ns, ex, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveNamespace(string? @namespace)
|
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildClientInitializationError()
|
|
||||||
{
|
|
||||||
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryGetClient(out IKubernetes client, out string error)
|
|
||||||
{
|
|
||||||
if (_client is null)
|
|
||||||
{
|
|
||||||
client = null!;
|
|
||||||
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
|
||||||
? string.Empty
|
|
||||||
: $" Детали: {_clientInitializationError}";
|
|
||||||
error = BuildClientInitializationError() + details;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
client = _client;
|
|
||||||
error = string.Empty;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
|
||||||
{
|
|
||||||
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
|
||||||
|
|
||||||
if (exception is 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}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,30 @@ using System.Text.Json;
|
|||||||
using k8s;
|
using k8s;
|
||||||
using k8s.Autorest;
|
using k8s.Autorest;
|
||||||
using k8s.Models;
|
using k8s.Models;
|
||||||
|
using LazyBear.MCP.Services.ToolRegistry;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
namespace LazyBear.MCP.Services.Kubernetes;
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
public sealed class K8sDeploymentTools(
|
||||||
|
K8sClientProvider clientProvider,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ToolRegistryService registry,
|
||||||
|
ILogger<K8sDeploymentTools>? logger = null)
|
||||||
|
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||||
{
|
{
|
||||||
private readonly IKubernetes? _client = clientProvider.Client;
|
private const int MinReplicas = 0;
|
||||||
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
private const int MaxReplicas = 100;
|
||||||
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
|
||||||
|
|
||||||
[McpServerTool, Description("Список deployment в namespace")]
|
[McpServerTool, Description("Список deployment в namespace")]
|
||||||
public async Task<string> ListDeployments(
|
public async Task<string> ListDeployments(
|
||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListDeployments", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -58,6 +66,16 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -87,6 +105,10 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetRolloutStatus", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateResourceName(name, nameof(name));
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -116,6 +138,10 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("RestartDeployment", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateResourceName(name, nameof(name));
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -151,49 +177,4 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
|||||||
return FormatError("restart_deployment", ns, ex, name);
|
return FormatError("restart_deployment", ns, ex, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveNamespace(string? @namespace)
|
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildClientInitializationError()
|
|
||||||
{
|
|
||||||
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryGetClient(out IKubernetes client, out string error)
|
|
||||||
{
|
|
||||||
if (_client is null)
|
|
||||||
{
|
|
||||||
client = null!;
|
|
||||||
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
|
||||||
? string.Empty
|
|
||||||
: $" Детали: {_clientInitializationError}";
|
|
||||||
error = BuildClientInitializationError() + details;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
client = _client;
|
|
||||||
error = string.Empty;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
|
||||||
{
|
|
||||||
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
|
||||||
|
|
||||||
if (exception is 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}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using k8s;
|
using k8s;
|
||||||
using k8s.Autorest;
|
using k8s.Autorest;
|
||||||
|
using LazyBear.MCP.Services.ToolRegistry;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
namespace LazyBear.MCP.Services.Kubernetes;
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
public sealed class K8sNetworkTools(
|
||||||
|
K8sClientProvider clientProvider,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ToolRegistryService registry,
|
||||||
|
ILogger<K8sNetworkTools>? logger = null)
|
||||||
|
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||||
{
|
{
|
||||||
private readonly IKubernetes? _client = clientProvider.Client;
|
|
||||||
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
|
||||||
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
|
||||||
|
|
||||||
[McpServerTool, Description("Список service в namespace")]
|
[McpServerTool, Description("Список service в namespace")]
|
||||||
public async Task<string> ListServices(
|
public async Task<string> ListServices(
|
||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListServices", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -58,6 +63,10 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetServiceDetails", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateResourceName(name, nameof(name));
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -91,6 +100,9 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListIngresses", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -136,49 +148,4 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
|
|||||||
return FormatError("list_ingresses", ns, ex);
|
return FormatError("list_ingresses", ns, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveNamespace(string? @namespace)
|
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildClientInitializationError()
|
|
||||||
{
|
|
||||||
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryGetClient(out IKubernetes client, out string error)
|
|
||||||
{
|
|
||||||
if (_client is null)
|
|
||||||
{
|
|
||||||
client = null!;
|
|
||||||
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
|
||||||
? string.Empty
|
|
||||||
: $" Детали: {_clientInitializationError}";
|
|
||||||
error = BuildClientInitializationError() + details;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
client = _client;
|
|
||||||
error = string.Empty;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
|
||||||
{
|
|
||||||
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
|
||||||
|
|
||||||
if (exception is 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}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using k8s;
|
using k8s;
|
||||||
using k8s.Autorest;
|
using k8s.Autorest;
|
||||||
|
using LazyBear.MCP.Services.ToolRegistry;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
|
|
||||||
namespace LazyBear.MCP.Services.Kubernetes;
|
namespace LazyBear.MCP.Services.Kubernetes;
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration)
|
public sealed class K8sPodsTools(
|
||||||
|
K8sClientProvider clientProvider,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ToolRegistryService registry,
|
||||||
|
ILogger<K8sPodsTools>? logger = null)
|
||||||
|
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||||
{
|
{
|
||||||
private readonly IKubernetes? _client = clientProvider.Client;
|
private const int MaxTailLines = 10;
|
||||||
private readonly string? _clientInitializationError = clientProvider.InitializationError;
|
private const int MinTailLines = 10;
|
||||||
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
|
||||||
|
|
||||||
[McpServerTool, Description("Список подов в namespace")]
|
[McpServerTool, Description("Список подов в namespace")]
|
||||||
public async Task<string> ListPods(
|
public async Task<string> ListPods(
|
||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("ListPods", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -55,6 +63,10 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
|||||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (!TryCheckEnabled("GetPodStatus", out var enabledError)) return enabledError;
|
||||||
|
|
||||||
|
ValidateResourceName(name, nameof(name));
|
||||||
|
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||||
var ns = ResolveNamespace(@namespace);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -88,6 +100,20 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
|||||||
int? tailLines = 100,
|
int? tailLines = 100,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
var ns = ResolveNamespace(@namespace);
|
||||||
if (!TryGetClient(out var client, out var clientError))
|
if (!TryGetClient(out var client, out var clientError))
|
||||||
{
|
{
|
||||||
@@ -100,7 +126,7 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
|||||||
name,
|
name,
|
||||||
ns,
|
ns,
|
||||||
container: container,
|
container: container,
|
||||||
tailLines: tailLines,
|
tailLines: (int?)tailLines,
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
using var reader = new StreamReader(logStream);
|
using var reader = new StreamReader(logStream);
|
||||||
@@ -118,49 +144,4 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
|||||||
return FormatError("get_pod_logs", ns, ex, name);
|
return FormatError("get_pod_logs", ns, ex, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveNamespace(string? @namespace)
|
|
||||||
{
|
|
||||||
return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildClientInitializationError()
|
|
||||||
{
|
|
||||||
return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT).";
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryGetClient(out IKubernetes client, out string error)
|
|
||||||
{
|
|
||||||
if (_client is null)
|
|
||||||
{
|
|
||||||
client = null!;
|
|
||||||
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
|
|
||||||
? string.Empty
|
|
||||||
: $" Детали: {_clientInitializationError}";
|
|
||||||
error = BuildClientInitializationError() + details;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
client = _client;
|
|
||||||
error = string.Empty;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null)
|
|
||||||
{
|
|
||||||
var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'";
|
|
||||||
|
|
||||||
if (exception is 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}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
LazyBear.MCP/Services/Kubernetes/KubernetesToolModule.cs
Normal 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"
|
||||||
|
];
|
||||||
|
}
|
||||||
125
LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
47
LazyBear.MCP/Services/Logging/InMemoryLogSink.cs
Normal 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();
|
||||||
|
}
|
||||||
42
LazyBear.MCP/Services/Logging/InMemoryLoggerProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
LazyBear.MCP/Services/Logging/LogEntry.cs
Normal 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;
|
||||||
|
}
|
||||||
66
LazyBear.MCP/Services/Mcp/McpWebHostedService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
LazyBear.MCP/Services/Qdrant/QdrantClientProvider.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
333
LazyBear.MCP/Services/Qdrant/QdrantKnowledgeTools.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
18
LazyBear.MCP/Services/Qdrant/QdrantToolModule.cs
Normal 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"
|
||||||
|
];
|
||||||
|
}
|
||||||
17
LazyBear.MCP/Services/ToolRegistry/IToolModule.cs
Normal 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; }
|
||||||
|
}
|
||||||
114
LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs
Normal 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}";
|
||||||
|
}
|
||||||
738
LazyBear.MCP/TUI/Components/App.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
LazyBear.MCP/TUI/Components/LogsTab.razor
Normal 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)] + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
65
LazyBear.MCP/TUI/Components/OverviewTab.razor
Normal 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)] + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
79
LazyBear.MCP/TUI/Components/SettingsTab.razor
Normal 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)] + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
21
LazyBear.MCP/TUI/Components/ToolButtonList.razor
Normal 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;
|
||||||
|
}
|
||||||
10
LazyBear.MCP/TUI/Components/_Imports.razor
Normal 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
|
||||||
74
LazyBear.MCP/TUI/GlobalKeyboardService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
LazyBear.MCP/TUI/Localization/Locale.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace LazyBear.MCP.TUI.Localization;
|
||||||
|
|
||||||
|
public enum Locale
|
||||||
|
{
|
||||||
|
En = 0,
|
||||||
|
Ru = 1
|
||||||
|
}
|
||||||
25
LazyBear.MCP/TUI/Localization/LocalizationService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
125
LazyBear.MCP/TUI/Localization/TuiResources.cs
Normal 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 = "(модуль ВЫКЛ, состояние инструментов сохранено)"
|
||||||
|
};
|
||||||
|
}
|
||||||
8
LazyBear.MCP/TUI/Models/OverviewRow.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LazyBear.MCP.TUI.Models;
|
||||||
|
|
||||||
|
public sealed record OverviewRow(
|
||||||
|
string ModuleName,
|
||||||
|
string Description,
|
||||||
|
bool IsModuleEnabled,
|
||||||
|
int ConfiguredTools,
|
||||||
|
int TotalTools);
|
||||||
18
LazyBear.MCP/TUI/Models/SettingsEntry.cs
Normal 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);
|
||||||
43
LazyBear.MCP/TUI/UiMetrics.cs
Normal 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;
|
||||||
|
}
|
||||||
23
LazyBear.MCP/TUI/UiPalette.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"Kubernetes": {
|
"Kubernetes": {
|
||||||
"KubeconfigPath": "",
|
"KubeconfigPath": "",
|
||||||
"DefaultNamespace": "default"
|
"DefaultNamespace": "default"
|
||||||
@@ -8,6 +8,22 @@
|
|||||||
"Token": "",
|
"Token": "",
|
||||||
"Project": ""
|
"Project": ""
|
||||||
},
|
},
|
||||||
|
"Confluence": {
|
||||||
|
"Url": "",
|
||||||
|
"Token": "",
|
||||||
|
"Username": "",
|
||||||
|
"SpaceKey": ""
|
||||||
|
},
|
||||||
|
"GitLab": {
|
||||||
|
"Url": "",
|
||||||
|
"Token": "",
|
||||||
|
"Project": ""
|
||||||
|
},
|
||||||
|
"Qdrant": {
|
||||||
|
"Url": "",
|
||||||
|
"ApiKey": "",
|
||||||
|
"DefaultCollection": "knowledge"
|
||||||
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 153 KiB |
37
LazyBearWorks.sln
Normal 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
|
||||||
443
README.md
@@ -1,8 +1,265 @@
|
|||||||
# LazyBear MCP Server
|
# LazyBear MCP Server
|
||||||
|
|
||||||
.NET 10 сервер Model Context Protocol (MCP) для интеграции Kubernetes инструментов.
|

|
||||||
|
**.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/
|
LazyBear.MCP/
|
||||||
├── Program.cs # HTTP transport MCP сервер
|
├── Program.cs # HTTP transport MCP сервер
|
||||||
@@ -10,28 +267,82 @@ LazyBear.MCP/
|
|||||||
│ ├── Index.cshtml # Главная страница
|
│ ├── Index.cshtml # Главная страница
|
||||||
│ └── Shared/ # Общие компоненты
|
│ └── Shared/ # Общие компоненты
|
||||||
├── Services/
|
├── Services/
|
||||||
│ └── Kubernetes/
|
│ ├── Jira/
|
||||||
│ ├── K8sConfigTools.cs # Инструменты конфигурации
|
│ │ └── JiraIssueTools.cs # Инструменты для Jira
|
||||||
│ ├── K8sDeploymentTools.cs # Инструменты деплоя
|
│ ├── Confluence/
|
||||||
│ ├── K8sNetworkTools.cs # Инструменты сети
|
│ │ └── ConfluencePagesTools.cs # Инструменты для Confluence
|
||||||
│ ├── K8sPodsTools.cs # Инструменты подов
|
│ ├── Kubernetes/
|
||||||
│ ├── K8sClientFactory.cs # Factory для клиентов
|
│ │ ├── K8sConfigTools.cs # Инструменты конфигурации
|
||||||
│ └── K8sClientProvider.cs # Provider для клиентов
|
│ │ ├── K8sDeploymentTools.cs # Инструменты деплоя
|
||||||
└── appsettings.json # Конфиг (логинг, K8s, allowed hosts)
|
│ │ ├── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Быстрый старт
|
---
|
||||||
|
|
||||||
```bash
|
## 🖥️ Интерактивная панель
|
||||||
cd LazyBear.MCP
|
|
||||||
dotnet run
|
```
|
||||||
|
┌─...──────────────────────────────────────────────┐
|
||||||
|
│ Dashboard: Обзор состояния кластера │
|
||||||
|
├─...──────────────────────────────────────────────┤
|
||||||
|
│ Logs & Events: Журналы событий │
|
||||||
|
│ Containers & Images: Контейнеры │
|
||||||
|
│ Workloads & Nodes: Распределение │
|
||||||
|
└─...──────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Сервер работает на `http://localhost:5000`
|
**Настройка в 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 конфигурация)
|
---
|
||||||
|
|
||||||
|
## 🔌 Интеграция
|
||||||
|
|
||||||
|
### Codex (Windows)
|
||||||
|
|
||||||
Файл: `.mcp.json`
|
Файл: `.mcp.json`
|
||||||
|
|
||||||
@@ -46,7 +357,7 @@ dotnet run
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Continue (расширение VS Code)
|
### Continue (VS Code)
|
||||||
|
|
||||||
Файл: `.vscode/continue/config.json`
|
Файл: `.vscode/continue/config.json`
|
||||||
|
|
||||||
@@ -55,18 +366,14 @@ dotnet run
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"lazybear": {
|
"lazybear": {
|
||||||
"command": "dotnet",
|
"command": "dotnet",
|
||||||
"args": [
|
"args": ["run", "--project", "${workspaceFolder}/LazyBear.MCP"],
|
||||||
"run",
|
|
||||||
"--project",
|
|
||||||
"${workspaceFolder}/LazyBear.MCP"
|
|
||||||
],
|
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenCode (Linux/Mac конфигурация)
|
### OpenCode (Linux/Mac)
|
||||||
|
|
||||||
Файл: `~/.opencode/.mcp.json`
|
Файл: `~/.opencode/.mcp.json`
|
||||||
|
|
||||||
@@ -81,68 +388,82 @@ dotnet run
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Использование через CLI
|
### MCP Inspector
|
||||||
|
|
||||||
Тестирование через MCP inspector:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g @modelcontextprotocol/inspector
|
npm install -g @modelcontextprotocol/inspector
|
||||||
|
|
||||||
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
|
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
|
||||||
```
|
```
|
||||||
|
|
||||||
Прямое тестирование через stdin:
|
---
|
||||||
|
|
||||||
|
## 🔧 CLI тестирование
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Доступные инструменты
|
---
|
||||||
|
|
||||||
### Kubernetes
|
## 🛠️ Разработка
|
||||||
|
|
||||||
| Инструмент | Описание |
|
|
||||||
|------------|-----------|
|
|
||||||
| `K8sConfigTools` | Управление конфигурациями K8s |
|
|
||||||
| `K8sDeploymentTools` | Управление деплоями |
|
|
||||||
| `K8sNetworkTools` | Управление сетями |
|
|
||||||
| `K8sPodsTools` | Управление подами |
|
|
||||||
|
|
||||||
**Настройка:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
// appsettings.json
|
|
||||||
{
|
|
||||||
"Kubernetes": {
|
|
||||||
"KubeconfigPath": "~/.kube/config",
|
|
||||||
"DefaultNamespace": "default"
|
|
||||||
},
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"ModelContextProtocol": "Debug"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
### Сборка
|
### Сборка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet build
|
dotnet build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Тестирование с MCP Inspector
|
### Тестирование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
|
npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
---
|
||||||
|
|
||||||
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*
|
||||||
26
docs/opencode/question-policy.md
Normal 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.
|
||||||
34
docs/razorconsole/README.md
Normal 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).
|
||||||
418
docs/razorconsole/components.md
Normal 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
|
||||||
|
|
||||||
|
Вставляет один перенос строки. Параметров нет.
|
||||||
69
docs/razorconsole/contributing.md
Normal 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` — сборка, тесты, паковка и публикация. Версии следуют семантическому версионированию.
|
||||||
276
docs/razorconsole/custom-translators.md
Normal 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">` |
|
||||||
|
| 70–80 | Button translators | `<button>` |
|
||||||
|
| 90 | SyntaxHighlighterElementTranslator | `<div class="syntax-highlighter">` |
|
||||||
|
| 100–190 | 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);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила выбора приоритета
|
||||||
|
|
||||||
|
| Диапазон | Когда использовать |
|
||||||
|
|---|---|
|
||||||
|
| 1–9 | Переопределить встроенное поведение |
|
||||||
|
| 10–190 | Вклиниться между конкретными встроенными трансляторами |
|
||||||
|
| 200–999 | Общие кастомные трансляторы |
|
||||||
|
| 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
116
docs/razorconsole/overview.md
Normal 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
@@ -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 эти вызовы бросают исключение.
|
||||||
148
memory-bank/activeContext.md
Normal 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 работает в фоне
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Файл автоматически обновляется при значимых изменениях проекта. Читать при начале новой сессии.*
|
||||||
214
memory-bank/implementation_plan.md
Normal 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. Тестирование и отладка
|
||||||
183
memory-bank/productContext.md
Normal 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
@@ -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 при значимых изменениях
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Файл описывает что работает, что осталось, известные проблемы и прогресс разработки. Обновлять после значимых изменений проекта.*
|
||||||
63
memory-bank/projectbrief.md
Normal 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. Источник правды: код и конфигурация проекта.*
|
||||||
322
memory-bank/systemPatterns.md
Normal 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
@@ -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 и важные примечания о разработке. Обновлять при введении новых технологий или зависимостей.*
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"model": "ollama/qwen3.5-agent"
|
"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
|
After Width: | Height: | Size: 741 KiB |
BIN
resources/icon_v2.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
resources/icon_v3.ico
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
resources/icon_v3.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 172 KiB |