Compare commits

...

6 Commits

43 changed files with 5294 additions and 79 deletions

67
.clinerules Normal file
View File

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

3
.gitignore vendored
View File

@@ -19,3 +19,6 @@ temp
# Claude Code
.claude/
LazyBear.MCP/LazyBear.MCP.csproj
/artifacts

View File

@@ -7,6 +7,7 @@
<UseAppHost>false</UseAppHost>
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
<ApplicationIcon>wwwroot\favicon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,8 +1,10 @@
using LazyBear.MCP.Services.Confluence;
using LazyBear.MCP.Services.GitLab;
using LazyBear.MCP.Services.Jira;
using LazyBear.MCP.Services.Kubernetes;
using LazyBear.MCP.Services.Logging;
using LazyBear.MCP.Services.Mcp;
using LazyBear.MCP.Services.Qdrant;
using LazyBear.MCP.Services.ToolRegistry;
using LazyBear.MCP.TUI;
using LazyBear.MCP.TUI.Components;
@@ -25,11 +27,15 @@ var host = Host.CreateDefaultBuilder(args)
services.AddSingleton<K8sClientProvider>();
services.AddSingleton<JiraClientProvider>();
services.AddSingleton<ConfluenceClientProvider>();
services.AddSingleton<GitLabClientProvider>();
services.AddSingleton<QdrantClientProvider>();
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
services.AddSingleton<IToolModule, JiraToolModule>();
services.AddSingleton<IToolModule, KubernetesToolModule>();
services.AddSingleton<IToolModule, ConfluenceToolModule>();
services.AddSingleton<IToolModule, GitLabToolModule>();
services.AddSingleton<IToolModule, QdrantToolModule>();
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
services.AddHostedService<McpWebHostedService>();

View File

@@ -4,7 +4,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5079",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,37 +9,31 @@
@implements IDisposable
<Rows Expand="true">
<Panel Title="@($"LazyBear MCP [{Localization.Label}]")"
TitleColor="@UiPalette.Accent"
BorderColor="@UiPalette.Frame"
<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">
<Markup Content="@Localization.Current.HintBar" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@foreach (var tab in _tabs)
{
var isActive = _activeTab == tab;
<Markup Content="@($" {GetTabLabel(tab)} ")"
Foreground="@(isActive ? UiPalette.SelectionForeground : UiPalette.Text)"
Background="@(isActive ? UiPalette.Accent : UiPalette.SurfaceMuted)"
Decoration="@(isActive ? Spectre.Console.Decoration.Bold : Spectre.Console.Decoration.None)" />
<Markup Content=" " />
}
</Columns>
<Markup Content=" " />
@if (_activeTab == Tab.Overview)
{
<OverviewTab Rows="@GetOverviewRows()"
SelectedIndex="@_overviewSelection"
SelectedIndexChanged="@OnOverviewSelectionChanged"
ViewportRows="@GetOverviewViewportRows()"
Endpoint="@_mcpEndpoint"
Loc="@Localization.Current" />
}
else if (_activeTab == Tab.Logs)
@@ -62,9 +56,38 @@
}
</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,
@@ -72,6 +95,12 @@
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);
@@ -82,14 +111,16 @@
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(Console.WindowHeight - 2, 10);
private static int GetOverviewViewportRows() => Math.Max(Console.WindowHeight - 11, 3);
private static int GetLogsViewportRows() => Math.Max(Console.WindowHeight - 16, 5);
private static int GetSettingsViewportRows() => Math.Max(Console.WindowHeight - 13, 5);
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()
{
@@ -115,6 +146,7 @@
ConsoleKey.PageUp => "PageUp",
ConsoleKey.PageDown => "PageDown",
ConsoleKey.Tab => "Tab",
ConsoleKey.Escape => "Escape",
_ => key.KeyChar == '\0' ? string.Empty : key.KeyChar.ToString()
};
@@ -145,6 +177,22 @@
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);
@@ -559,6 +607,127 @@
_ => 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;

View File

@@ -2,7 +2,6 @@
<Rows>
<Markup Content="@Loc.LogsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.LogsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Columns>
@@ -87,7 +86,7 @@
? selected.Message
: $"{selected.Message} | {selected.Exception}";
return Fit(details, Math.Max(Console.WindowWidth - 12, 32));
return Fit(details, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private string FormatEntry(int index)
@@ -104,7 +103,7 @@
};
var text = $"{entry.Timestamp:HH:mm:ss} {level,-3} {entry.ShortCategory,-18} {entry.Message}";
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private static string Fit(string text, int width)

View File

@@ -1,8 +1,5 @@
<Rows>
<Markup Content="@Loc.OverviewTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.OverviewHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
<Markup Content="@($"{Loc.DashboardEndpointLabel}: {Endpoint}")" Foreground="@UiPalette.Accent" />
<Markup Content=" " />
@if (Rows.Count == 0)
@@ -32,7 +29,6 @@
[Parameter] public int SelectedIndex { get; set; }
[Parameter] public EventCallback<int> SelectedIndexChanged { get; set; }
[Parameter] public int ViewportRows { get; set; } = 3;
[Parameter] public string Endpoint { get; set; } = "";
[Parameter] public TuiResources Loc { get; set; } = TuiResources.En;
private int[] GetOptions() => Enumerable.Range(0, Rows.Count).ToArray();
@@ -56,7 +52,7 @@
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(Console.WindowWidth - 12, 32));
return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private static string Fit(string text, int width)

View File

@@ -1,6 +1,5 @@
<Rows>
<Markup Content="@Loc.SettingsTitle" Foreground="@UiPalette.Text" Decoration="@Spectre.Console.Decoration.Bold" />
<Markup Content="@Loc.SettingsHint" Foreground="@UiPalette.TextMuted" />
<Markup Content=" " />
@if (Entries.Count == 0)
@@ -67,7 +66,7 @@
text = $"{indent}{checkbox} {entry.Label}{disabledSuffix}";
}
return Fit(text, Math.Max(Console.WindowWidth - 12, 32));
return Fit(text, Math.Max(UiMetrics.ConsoleWidth - 12, 32));
}
private static string Fit(string text, int width)

View File

@@ -7,10 +7,15 @@ namespace LazyBear.MCP.TUI.Localization;
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; } = "";
@@ -44,10 +49,15 @@ public sealed record TuiResources
public static readonly TuiResources En = new()
{
HintBar = "Tab: tabs | Arrows: navigate | Space: toggle | Enter: open | L: language | Q: quit",
TabOverview = "Dashboard",
TabLogs = "Logs",
TabSettings = "Settings",
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.",
@@ -77,10 +87,15 @@ public sealed record TuiResources
public static readonly TuiResources Ru = new()
{
HintBar = "Tab: вкладки | Стрелки: навигация | Space: вкл/выкл | Enter: открыть | L: язык | Q: выход",
TabOverview = "Dashboard",
TabLogs = "Логи",
TabSettings = "Настройки",
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: открыть настройки.",

View File

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

View File

@@ -8,6 +8,8 @@ internal static class UiPalette
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;

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 153 KiB

169
README.md
View File

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

BIN
logo.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

BIN
resources/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 KiB

BIN
resources/icon_v2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
resources/icon_v3.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
resources/icon_v3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB