Files
LazyBearWorks/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs
Shahovalov MIkhail 879becadfe feat: внедрение RazorConsole TUI с runtime-управлением MCP-инструментами
- Добавлен RazorConsole.Core для интерактивного TUI-дашборда
- ToolRegistryService: живое включение/отключение модулей и отдельных методов
- InMemoryLogSink: кольцевой буфер логов с фильтрацией по модулю
- TUI: 3 таба (Overview, Logs, Settings)
- IToolModule: generic-интерфейс для легкого добавления новых MCP-модулей
- Guard-проверка TryCheckEnabled() во всех существующих инструментах
2026-04-13 17:31:28 +03:00

429 lines
16 KiB
C#

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