Confluence

This commit is contained in:
2026-04-13 15:57:56 +03:00
parent 2fe64d0903
commit 8ac5ad2bac
6 changed files with 459 additions and 5 deletions

View File

@@ -1,8 +1,7 @@
## AGENTS.md ## AGENTS.md
### Scope & Source of Truth ### Scope & Source of Truth
- Work in `LazyBear.MCP/`. - Work in `/`.
- Ignore `Libraries/Confluence/` unless the user explicitly asks for it.
- Trust code and project config over `README.md`. - Trust code and project config over `README.md`.
- Primary source of truth: `LazyBear.MCP/Program.cs`. - Primary source of truth: `LazyBear.MCP/Program.cs`.

View File

@@ -1,3 +1,4 @@
using LazyBear.MCP.Services.Confluence;
using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Jira;
using LazyBear.MCP.Services.Kubernetes; using LazyBear.MCP.Services.Kubernetes;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
@@ -6,6 +7,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<K8sClientProvider>(); builder.Services.AddSingleton<K8sClientProvider>();
builder.Services.AddSingleton<JiraClientProvider>(); builder.Services.AddSingleton<JiraClientProvider>();
builder.Services.AddSingleton<ConfluenceClientProvider>();
builder.Services.AddMcpServer() builder.Services.AddMcpServer()
.WithHttpTransport() .WithHttpTransport()

View File

@@ -0,0 +1,25 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Confluence;
public static class ConfluenceClientFactory
{
public static RestClient CreateClient(IConfiguration configuration)
{
var confluenceUrl = configuration["Confluence:Url"] ?? "";
if (string.IsNullOrWhiteSpace(confluenceUrl))
{
throw new Exception("Confluence:Url нe задан");
}
var config = new RestClientOptions(confluenceUrl)
{
UserAgent = "LazyBear-Confluence-MCP",
Timeout = TimeSpan.FromMilliseconds(30000)
};
return new RestClient(config);
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Confluence;
public sealed class ConfluenceClientProvider
{
public RestClient? Client { get; }
public string? InitializationError { get; }
public ConfluenceClientProvider(IConfiguration configuration)
{
try
{
Client = ConfluenceClientFactory.CreateClient(configuration);
}
catch (Exception ex)
{
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
}
}
}

View File

@@ -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<string> 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<string> 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<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 (!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 (!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 (!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 (!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}";
}
}

View File

@@ -1,4 +1,4 @@
{ {
"Kubernetes": { "Kubernetes": {
"KubeconfigPath": "", "KubeconfigPath": "",
"DefaultNamespace": "default" "DefaultNamespace": "default"
@@ -8,6 +8,12 @@
"Token": "", "Token": "",
"Project": "" "Project": ""
}, },
"Confluence": {
"Url": "",
"Token": "",
"Username": "",
"SpaceKey": ""
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",