Confluence
This commit is contained in:
25
LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs
Normal file
25
LazyBear.MCP/Services/Confluence/ConfluenceClientFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs
Normal file
23
LazyBear.MCP/Services/Confluence/ConfluenceClientProvider.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
399
LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs
Normal file
399
LazyBear.MCP/Services/Confluence/ConfluencePagesTools.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user