From 8ac5ad2bac462d750b081c1ad5113471cdfc06e4 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Mon, 13 Apr 2026 15:57:56 +0300 Subject: [PATCH] Confluence --- AGENTS.md | 3 +- LazyBear.MCP/Program.cs | 4 +- .../Confluence/ConfluenceClientFactory.cs | 25 ++ .../Confluence/ConfluenceClientProvider.cs | 23 + .../Confluence/ConfluencePagesTools.cs | 399 ++++++++++++++++++ LazyBear.MCP/appsettings.json | 10 +- 6 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs create mode 100644 LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs create mode 100644 LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs diff --git a/AGENTS.md b/AGENTS.md index 2c46243..b8ba6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,7 @@ ## AGENTS.md ### Scope & Source of Truth -- Work in `LazyBear.MCP/`. -- Ignore `Libraries/Confluence/` unless the user explicitly asks for it. +- Work in `/`. - Trust code and project config over `README.md`. - Primary source of truth: `LazyBear.MCP/Program.cs`. diff --git a/LazyBear.MCP/Program.cs b/LazyBear.MCP/Program.cs index 75639cf..5f87554 100644 --- a/LazyBear.MCP/Program.cs +++ b/LazyBear.MCP/Program.cs @@ -1,3 +1,4 @@ +using LazyBear.MCP.Services.Confluence; using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Kubernetes; using ModelContextProtocol.Server; @@ -6,6 +7,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithHttpTransport() @@ -15,4 +17,4 @@ var app = builder.Build(); app.MapMcp(); -app.Run("http://localhost:5000"); +app.Run("http://localhost:5000"); \ No newline at end of file diff --git a/LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs b/LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs new file mode 100644 index 0000000..936ad43 --- /dev/null +++ b/LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Configuration; +using RestSharp; + +namespace LazyBear.MCP.Services.Confluence; + +public static class ConfluenceClientFactory +{ + public static RestClient CreateClient(IConfiguration configuration) + { + var confluenceUrl = configuration["Confluence:Url"] ?? ""; + + if (string.IsNullOrWhiteSpace(confluenceUrl)) + { + throw new Exception("Confluence:Url нe задан"); + } + + var config = new RestClientOptions(confluenceUrl) + { + UserAgent = "LazyBear-Confluence-MCP", + Timeout = TimeSpan.FromMilliseconds(30000) + }; + + return new RestClient(config); + } +} diff --git a/LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs b/LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs new file mode 100644 index 0000000..966cabd --- /dev/null +++ b/LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using RestSharp; + +namespace LazyBear.MCP.Services.Confluence; + +public sealed class ConfluenceClientProvider +{ + public RestClient? Client { get; } + + public string? InitializationError { get; } + + public ConfluenceClientProvider(IConfiguration configuration) + { + try + { + Client = ConfluenceClientFactory.CreateClient(configuration); + } + catch (Exception ex) + { + InitializationError = $"{ex.GetType().Name}: {ex.Message}"; + } + } +} diff --git a/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs b/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs new file mode 100644 index 0000000..22fe2e9 --- /dev/null +++ b/LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs @@ -0,0 +1,399 @@ +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}"; + } +} diff --git a/LazyBear.MCP/appsettings.json b/LazyBear.MCP/appsettings.json index 51bf340..1c435f8 100644 --- a/LazyBear.MCP/appsettings.json +++ b/LazyBear.MCP/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Kubernetes": { "KubeconfigPath": "", "DefaultNamespace": "default" @@ -8,6 +8,12 @@ "Token": "", "Project": "" }, + "Confluence": { + "Url": "", + "Token": "", + "Username": "", + "SpaceKey": "" + }, "Logging": { "LogLevel": { "Default": "Information", @@ -16,4 +22,4 @@ } }, "AllowedHosts": "*" -} +} \ No newline at end of file