using System.ComponentModel; using System.Text; using System.Text.Json; using ModelContextProtocol.Server; using RestSharp; namespace LazyBear.MCP.Services.Confluence; [McpServerToolType] public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, IConfiguration configuration) { 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; [McpServerTool, Description("Получить страницу Confluence по ID")] public async Task GetPage( [Description("ID страницы Confluence")] string pageId, CancellationToken cancellationToken = default) { 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 SearchPages( [Description("CQL запрос. Если пусто, используется пространство по умолчанию")] string? cql = null, [Description("Максимум страниц в ответе")] int maxResults = 20, CancellationToken cancellationToken = default) { 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(); 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 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 (!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 { ["type"] = "page", ["title"] = title, ["space"] = new Dictionary { ["key"] = resolvedSpace }, ["body"] = new Dictionary { ["storage"] = new Dictionary { ["value"] = bodyStorage, ["representation"] = "storage" } } }; if (!string.IsNullOrWhiteSpace(parentId)) { bodyObj["ancestors"] = new[] { new Dictionary { ["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 UpdatePage( [Description("ID страницы для обновления")] string pageId, [Description("Новое содержимое страницы в формате storage (HTML-like WikiMarkup)")] string bodyStorage, [Description("Номер текущей версии страницы")] int version, [Description("Новое название страницы. Если пусто, не меняется")] string? title = null, CancellationToken cancellationToken = default) { 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 DeletePage( [Description("ID страницы для удаления")] string pageId, CancellationToken cancellationToken = default) { 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 GetSpace( [Description("Ключ пространства")] string spaceKey, CancellationToken cancellationToken = default) { 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}"; } }