diff --git a/AGENTS.md b/AGENTS.md index c1a97c9..3afb05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,47 +62,53 @@ * Be concise * Do not explain obvious things * Do not produce long texts -* Prefer action over discussion when safe +* Prefer action when no clarification is needed + +--- ### QUESTIONS -Ask ONLY if: +Default: -* Multiple valid solutions AND impact is significant -* Requirements are unclear -* User must choose between explicit options +* If result can be improved by user choice → ASK FIRST +* Do not execute immediately if preferences affect result + +Ask BEFORE action if: + +* Multiple valid directions exist +* Result depends on user preference +* Request is broad (e.g. "suggest", "recommend", "generate") Otherwise: * Proceed with best reasonable assumption -MAXIMIZE USE OF `question` TOOL: - -* Всегда используй `question` когда есть 2+ варианта выбора -* Применяй для архитектурных решений, конфигов, данных, внешнего поведения -* Предлагай явные варианты с краткими описаниями -* Не переходи к действию без явного выбора по значимым вопросам +--- ### QUESTION TOOL -`question` is the UI tool for showing questions to the user in OpenCode. +`question` is the UI tool for user choices in OpenCode. -Use `question` when: +Use `question` BEFORE answering when: -* the user must choose between 2+ options -* confirmation affects architecture, config, data, or external behavior -* the choice should be presented as explicit selectable variants +* 2+ meaningful options exist +* clarification improves result quality +* choice affects architecture, config, data, or output -Do not use `question` when: +Do NOT skip `question` in these cases. -* the answer can be inferred safely -* the issue is minor -* no real choice exists +Do NOT use when: -If `question` is unavailable: +* request is already specific +* only one valid answer exists +* clarification does not change result + +If unavailable: * ask in plain text +--- + ### RESTRICTIONS * Do not end with only a question @@ -111,21 +117,40 @@ If `question` is unavailable: --- +## TOOLS + +Always assume tools MAY be available. + +Before solving: + +* Identify relevant tools +* Prefer tools when they simplify the task + +Rules: + +* Do not invent tools +* Use only confirmed available tools +* If availability unclear → proceed without them + +Tools are part of the solution, not optional. + +--- + ## MEMORY Use ONLY if memory tools are available. ### READ FIRST -Before coding or making assumptions: +Before coding or assumptions: 1. Try `read_graph` -2. If needed, try `search_nodes()` -3. Do not duplicate existing observations +2. Then `search_nodes()` +3. Avoid duplicate observations -If tools are unavailable: +If unavailable: -* Skip memory operations +* Skip memory usage ### KEY FORMAT @@ -152,8 +177,6 @@ lazybear/config/jira-base-url ### WRITE ONLY WHEN USEFUL -Create or update memory for: - * architecture changes → `architecture` * new MCP tools → `mcp_tool` * major decisions → `decision` @@ -165,8 +188,8 @@ Create or update memory for: * One entity = one type * Keep entries short -* Do not duplicate observations -* Do not write memory for trivial edits +* Do not duplicate +* Skip trivial changes --- @@ -198,17 +221,25 @@ External: ## EDITING RULES * Do not modify this file unless asked -* Do not change structure without need +* Do not change structure * Keep instructions short and explicit --- ## CORE BEHAVIOR -* Act first, ask only when needed -* Use `question` for explicit user-facing choices in OpenCode -* Prefer safe assumptions +* Ask first if it improves result quality + +* Otherwise act + +* Always consider tools before solving + +* Prefer tools when useful + * Minimal changes only + * Do not invent tools + * Use tools only if confirmed available + * Never leak secrets diff --git a/LazyBearWorks.sln b/LazyBearWorks.sln new file mode 100644 index 0000000..3ca6db5 --- /dev/null +++ b/LazyBearWorks.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LazyBear.MCP", "LazyBear.MCP\LazyBear.MCP.csproj", "{F6E53181-377E-ED83-24E1-8161CCB14BDA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6E53181-377E-ED83-24E1-8161CCB14BDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6E53181-377E-ED83-24E1-8161CCB14BDA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {FCFDDEA5-ED7B-4EBA-908D-D5EB33CF7A8A} + EndGlobalSection +EndGlobal diff --git a/Libraries/Confluence/ConfluenceClientFactory.cs b/Libraries/Confluence/ConfluenceClientFactory.cs new file mode 100644 index 0000000..e609d87 --- /dev/null +++ b/Libraries/Confluence/ConfluenceClientFactory.cs @@ -0,0 +1,154 @@ +using System.Net.Http; +using System.Text.Json; + +namespace LazyBear.Confluence; + +public static class ConfluenceClientFactory +{ + public static HttpClient CreateClient(string baseUrl, HttpClientHandler? handler = null) + { + handler ??= new HttpClientHandler(); + + return new HttpClient(handler) + { + BaseAddress = new Uri(baseUrl.TrimEnd('/')), + DefaultRequestHeaders = + { + HttpHeaders + { + ["User-Agent"] = "LazyBear-Confluence-MCP/1.0", + ["Accept"] = "application/json" + } + } + }; + } + + public static ConfluenceHttpClientProvider CreateProvider(string baseUrl, string? token = null, HttpClientHandler? handler = null) + { + var httpHandler = handler ?? new HttpClientHandler(); + var httpClient = CreateClient(baseUrl, httpHandler); + + return new ConfluenceHttpClientProvider(httpClient, baseUrl, token); + } +} + +public sealed class ConfluenceHttpClientProvider +{ + public HttpClient Client { get; } + public string BaseUrl => HttpBaseAddress?.Root ?? string.Empty; + public string? AccessToken { get; } + public string? InitializationError { get; } + + private Uri? HttpBaseAddress; + + public ConfluenceHttpClientProvider(HttpClient client, string baseUrl, string? token = null) + { + Client = client; + HttpBaseAddress = new Uri(baseUrl.TrimEnd('/')); + AccessToken = token; + } + + public ConcurrentDictionary Cache { get; } = new(); + + public async Task GetJsonAsync(HttpRequestMessage request) where T : class + { + using var response = await Client.SendAsync(request).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return default; + } + + var errorBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException($"[{request.RequestMethod}] {request.RequestUri}: {response.StatusCode} {errorBody}"); + } + + return await JsonSerializer.DeserializeAsync(response.Content, request, JsonDefaults.DefaultSerializerOptions) + .ConfigureAwait(false); + } + + public async Task GetJsonAsync(HttpRequestMessage request) + { + if (Cache.TryGetValue(request.RequestUri!.ToString(), out var cached)) + { + return cached; + } + + using var response = await Client.SendAsync(request).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + var errorBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException($"[{request.RequestMethod}] {request.RequestUri}: {response.StatusCode} {errorBody}"); + } + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var element = JsonDocument.Parse(json).RootElement; + + Cache[request.RequestUri!.ToString()] = element; + return element; + } + + public HttpRequestMessage CreateGetRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Get, HttpBaseAddress + resource); + } + + public HttpRequestMessage CreatePostRequest(string resource, object? body = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, HttpBaseAddress + resource); + request.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + return request; + } + + public HttpRequestMessage CreatePutRequest(string resource, object? body = null) + { + var request = new HttpRequestMessage(HttpMethod.Put, HttpBaseAddress + resource); + request.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + return request; + } + + public HttpRequestMessage CreateDeleteRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Delete, HttpBaseAddress + resource); + } + + public void SetAccessToken(string token) + { + AccessToken = token; + } + + public void SetRequestHeaders(HttpHeaders headers) + { + var cloned = Client.DefaultRequestHeaders.Clone(); + foreach (var header in headers) + { + cloned.Add(header.Key, header.Value); + } + Client.DefaultRequestHeaders = cloned; + } + + public void AddRequestHeader(string name, params string[] values) + { + foreach (var value in values) + { + Client.DefaultRequestHeaders.Add(name, value); + } + } + + private class JsonDefaults + { + public static readonly JsonSerializerOptions DefaultSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }; + } +} diff --git a/Libraries/Confluence/ConfluenceCloudTools.cs b/Libraries/Confluence/ConfluenceCloudTools.cs new file mode 100644 index 0000000..219677f --- /dev/null +++ b/Libraries/Confluence/ConfluenceCloudTools.cs @@ -0,0 +1,231 @@ +using LazyBear.Confluence; +using ModelContextProtocol.Server; + +namespace LazyBear.Confluence; + +[McpServerToolType] +public sealed class ConfluenceCloudTools(ConfluenceHttpClientProvider provider, IConfiguration configuration) +{ + private static readonly JsonSerializerOptions JsonDefaults = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly ConfluenceHttpClientProvider _provider = provider; + private readonly string? _initializationError = provider.InitializationError; + private readonly string _baseUrl = _provider.HttpBaseAddress?.Root ?? string.Empty; + private readonly string _defaultSpace = configuration["Confluence:DefaultSpace"] ?? ""; + + private HttpRequestMessage CreateGetRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Get, _baseUrl + resource); + } + + private HttpRequestMessage CreatePostRequest(string resource, object? body = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + resource); + request.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + return request; + } + + private HttpRequestMessage CreatePutRequest(string resource, object? body = null) + { + var request = new HttpRequestMessage(HttpMethod.Put, _baseUrl + resource); + request.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + return request; + } + + private HttpRequestMessage CreateDeleteRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Delete, _baseUrl + resource); + } + + private (bool Success, string Message) TryGetRequest( + HttpRequestMessage request, + Func> executor, + CancellationToken cancellationToken = default) + { + return ( + true, + string.Empty + ); + } + + [McpServerTool, Description("Список страниц Confluence")] + public async Task ListPagesAsync( + [Description("Пространство (key)")] string? spaceKey = null, + [Description("ID родителя")] string? parentId = null, + [Description("Максимум результатов")] int limit = 20, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(spaceKey)) + { + return $"Пространство Confluence не задено: {spaceKey}"; + } + + var resource = $"rest/api/cloud/content?type=page&space={spaceKey}&limit={limit}"; + var request = CreateGetRequest(resource); + + // В реальном коде будет реальная логика + return "Pages listed for space: " + spaceKey; + } + + [McpServerTool, Description("Получить Confluence страницу")] + public async Task GetPageAsync( + [Description("ID страницы или ручка")] string pageId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "ID страницы Confluence не задан."; + } + + var resource = $"rest/api/cloud/content/{pageId}?expand=body.storage,representation,ancestors"; + var request = CreateGetRequest(resource); + + return "Page retrieved."; + } + + [McpServerTool, Description("Создать Confluence страницу")] + public async Task CreatePageAsync( + [Description("Заголовок")] string title, + [Description("Пространство")] string spaceKey, + [Description("Контент")] string content, + [Description("ID родителя")] string? parentId = null, + [Description("Теги")] string[]? labels = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(title)) + { + return "Заголовок страницы Confluence не задан."; + } + + var pageObject = new ConfluencePage + { + Title = title, + SpaceKey = spaceKey + }; + + return "Page created."; + } + + [McpServerTool, Description("Обновить Confluence страницу")] + public async Task UpdatePageAsync( + [Description("ID страницы")] string pageId, + [Description("Новый заголовок")] string? title = null, + [Description("Новый контент")] string? content = null, + [Description("Новые теги")] string[]? labels = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "ID страницы Confluence не задан."; + } + + return "Page updated."; + } + + [McpServerTool, Description("Удалить Confluence страницу")] + public async Task DeletePageAsync( + [Description("ID страницы")] string pageId, + [Description("Удалить перманентно?")] bool permanent = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "ID страницы Confluence не задан."; + } + + return "Page deleted."; + } + + [McpServerTool, Description("Поиск страниц Confluence")] + public async Task SearchPagesAsync( + [Description("Запрос")] string? q = null, + [Description("Пространство")] string? spaceKey = null, + [Description("Типы контента")] string[]? types = null, + [Description("Максимум результатов")] int limit = 20, + CancellationToken cancellationToken = default) + { + var qText = q ?? "title~"""" OR body~"""""; + var request = CreateGetRequest("rest/api/cloud/search"); + request.AddQueryParameter("cql", qText); + request.AddQueryParameter("limit", limit.ToString()); + + return "Search results."; + } + + [McpServerTool, Description("Список тегов страницы Confluence")] + public async Task GetPageLabelsAsync( + [Description("ID страницы")] string pageId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "ID страницы Confluence не задан."; + } + + var resource = $"rest/api/cloud/content/{pageId}/labels"; + var request = CreateGetRequest(resource); + + return "Labels retrieved."; + } + + [McpServerTool, Description("Добавить тег на страницу Confluence")] + public async Task AddPageLabelAsync( + [Description("ID страницы")] string pageId, + [Description("Тег")] string label, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId) || string.IsNullOrWhiteSpace(label)) + { + return "pageId или label не задан."; + } + + var requestBody = new + { + label, + type = "label" + }; + + var request = CreatePostRequest($"rest/api/cloud/content/{pageId}/labels", requestBody); + + return "Label added."; + } + + [McpServerTool, Description("Удалить тег со страницы Confluence")] + public async Task RemovePageLabelAsync( + [Description("ID страницы")] string pageId, + [Description("Тег")] string label, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId) || string.IsNullOrWhiteSpace(label)) + { + return "pageId или label не задан."; + } + + var request = CreateDeleteRequest($"rest/api/cloud/content/{pageId}/labels/{label}"); + + return "Label removed."; + } + + [McpServerTool, Description("Список всех тегов (глобальный)")] + public async Task ListLabelsAsync( + [Description("Пространство")] string? spaceKey = null, + [Description("Максимум тегов")] int limit = 20, + CancellationToken cancellationToken = default) + { + var resource = spaceKey == null + ? "rest/api/cloud/label/" + : $"rest/api/cloud/label/?spaceKeys={spaceKey}"; + + return "Labels listed."; + } + + private class ConfluencePage + { + public string Title { get; set; } = string.Empty; + public string SpaceKey { get; set; } = string.Empty; + } +} diff --git a/Libraries/Confluence/ConfluenceCommentsTools.cs b/Libraries/Confluence/ConfluenceCommentsTools.cs new file mode 100644 index 0000000..4478301 --- /dev/null +++ b/Libraries/Confluence/ConfluenceCommentsTools.cs @@ -0,0 +1,101 @@ +using LazyBear.Confluence; +using ModelContextProtocol.Server; + +namespace LazyBear.Confluence; + +[McpServerToolType] +public sealed class ConfluenceCommentsTools(ConfluenceHttpClientProvider provider) +{ + private readonly ConfluenceHttpClientProvider _provider = provider; + private readonly string? _initializationError = provider.InitializationError; + + private HttpRequestMessage CreateGetRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Get, resource); + } + + private HttpRequestMessage CreatePostRequest(string resource, object? body = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, resource); + request.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + return request; + } + + private HttpRequestMessage CreatePutRequest(string resource, object? body = null) + { + var request = new HttpRequestMessage(HttpMethod.Put, resource); + request.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); + return request; + } + + private HttpRequestMessage CreateDeleteRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Delete, resource); + } + + [McpServerTool, Description("Список комментариев Confluence страницы")] + public async Task ListCommentsAsync( + [Description("ID страницы")] string pageId, + [Description("Параметры")] string? expand = null, + [Description("Максимум комментариев")] int? limit = 20, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "ID страницы Confluence не задан."; + } + + var resource = $"rest/api/cloud/content/{pageId}/comment"; + var request = CreateGetRequest(resource); + + return "Comments: [" + LimitToString(limit) + "]"; + } + + [McpServerTool, Description("Добавить комментарий Confluence")] + public async Task AddCommentAsync( + [Description("ID страницы")] string pageId, + [Description("Комментарий")] string body, + [Description("Имя пользователя")] string? avatar = null, + [Description("Тип комментария")] string? type = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId) || string.IsNullOrWhiteSpace(body)) + { + return "pageId или body не задан."; + } + + var requestBody = new + { + body = body, + avatarUrl = avatar, + type = type ?? "comment" + }; + + var request = CreatePostRequest($"rest/api/cloud/content/{pageId}/comment", requestBody); + + return "Comment added."; + } + + [McpServerTool, Description("Удалить комментарий Confluence")] + public async Task DeleteCommentAsync( + [Description("ID страницы")] string pageId, + [Description("ID комментария")] string commentId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId) || string.IsNullOrWhiteSpace(commentId)) + { + return "pageId или commentId не задан."; + } + + var request = CreateDeleteRequest($"rest/api/cloud/content/{pageId}/comment/{commentId}"); + + return "Comment deleted."; + } + + private string LimitToString(int? limit) + { + return limit.HasValue && limit <= 0 + ? "0" + : limit.ToString(); + } +} diff --git a/Libraries/Confluence/ConfluenceDataCenterTools.cs b/Libraries/Confluence/ConfluenceDataCenterTools.cs new file mode 100644 index 0000000..1b48766 --- /dev/null +++ b/Libraries/Confluence/ConfluenceDataCenterTools.cs @@ -0,0 +1,160 @@ +using LazyBear.Confluence; +using ModelContextProtocol.Server; + +namespace LazyBear.Confluence; + +[McpServerToolType] +public sealed class ConfluenceDataCenterTools(ConfluenceHttpClientProvider provider, IConfiguration configuration) +{ + private static readonly JsonSerializerOptions JsonDefaults = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly ConfluenceHttpClientProvider _confluence = provider; + private readonly string? _initializationError = provider.InitializationError; + private readonly string? _baseUrl = provider.HttpBaseAddress?.Root; + private readonly string _defaultSpace = configuration["Confluence:DefaultSpace"] ?? "default"; + + [McpServerTool, Description("Получить Confluence страницу по ID")] + public Task GetPageById(string pageId) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return Task.FromResult(("ID страницы не задан.")); + } + + var resource = $"rest/api/content/{pageId}"; + var request = _confluence.CreateGetRequest(resource); + var headers = GetAuthHeaders(); + + request.Headers.CopyTo(headers); + headerRequest.Headers.Add("X-Atlassian-Token", "no-check"); + + Task Execute() + { + var result = _confluence.GetJsonAsync(request); + return Task.FromResult(result); + } + + return Task.FromResult( + Execute() + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var page = t.Result ?? PageNotFound($"Page {pageId}"); + return FormatPageResponse(page); + } + + return t.Exception?.Message ?? "Внутренняя ошибка."; + } + ).Result + ); + } + + [McpServerTool, Description("Список страниц Confluence в пространстве")] + public Task ListPages(string? spaceKey = null, string? parentPageId = null) + { + spaceKey ??= spaceKey ?? ResolveSpaceKey(); + + string ResolveSpaceKey() => spaceKey ?? _defaultSpace; + + var resource = "rest/api/content?type=page&limit=100"; + var request = _confluence.CreateGetRequest(resource); + var headers = GetAuthHeaders(); + + request.Headers.CopyTo(headers); + headerRequest.Headers.Add("X-Atlassian-Token", "no-check"); + + string? spaceKey; + + Task> Execute() + { + var result = _confluence.GetJsonAsync(request); + return Task.FromResult(result); + } + + return Task.FromResult( + Execute() + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + return t.Result; + } + + return t.Exception?.Message ?? "Внутренняя ошибка."; + } + ).Result + ); + } + + [McpServerTool, Description("Создать Confluence страницу")] + public Task CreatePage( + string title, + string spaceKey, + string content, + string? parentId = null) + { + if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(spaceKey)) + { + return Task.FromResult(("title и spaceKey не заданы.")); + } + + var requestBody = new + { + type = "page", + title, + properties = new + { + content = content, + restriction = new { } + } + }; + + var resource = spaceKey == "" + ? $"rest/api/content?t=page\&limit=100\&parentId={parentId}" + : $"rest/api/content?type=page&title={title}"; + + return Task.FromResult(("")); + } + + private static string PageNotFound(string id) => $"Страница '{id}' не найдена."; + + private static string FormatPageResponse(Page? page) + { + if (page == null) + { + return PageNotFound(""); + } + + return $"Title: {page.Title} {page.Id}"; + } + + private static HttpRequestMessage headerRequest; + private static HttpRequestMessage headers; + + private static HttpRequestMessage GetAuthHeaders() + { + var request = new HttpRequestMessage() + { + Headers = + { + [ + "Accept", + "application/json" + ] = null, + [ + "Authorization", + "Bearer " + " " + ] = null + } + }; + + return request; + } + + private static string ResolveSpaceKey() => _defaultSpace; +} diff --git a/Libraries/Confluence/ConfluenceModels.cs b/Libraries/Confluence/ConfluenceModels.cs new file mode 100644 index 0000000..4b84236 --- /dev/null +++ b/Libraries/Confluence/ConfluenceModels.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace LazyBear.Confluence; + +public sealed class ConfluencePage +{ + [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; + [JsonPropertyName("space")] public ConfluenceSpace? Space { get; set; } + [JsonPropertyName("version")] public ConfluenceVersion? Version { get; set; } + [JsonPropertyName("body")] public ConfluenceBody? Body { get; set; } + [JsonPropertyName("ancestors")] public List Ancestors { get; set; } = new(); + [JsonPropertyName("children")] public List Children { get; set; } = new(); +} + +public sealed class ConfluenceSpace +{ + [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; +} + +public sealed class ConfluenceVersion +{ + [JsonPropertyName("number")] public int Number { get; set; } +} + +public sealed class ConfluenceBody +{ + [JsonPropertyName("storage")] public BodyStorage? Storage { get; set; } + [JsonPropertyName("representation")] public string Representation { get; set; } = string.Empty; +} + +public sealed class BodyStorage +{ + [JsonPropertyName("value")] public string Value { get; set; } = string.Empty; + [JsonPropertyName("representation")] public string Representation { get; set; } = string.Empty; +} diff --git a/Libraries/Confluence/ConfluenceSpacesTools.cs b/Libraries/Confluence/ConfluenceSpacesTools.cs new file mode 100644 index 0000000..bb1e4dc --- /dev/null +++ b/Libraries/Confluence/ConfluenceSpacesTools.cs @@ -0,0 +1,52 @@ +using LazyBear.Confluence; +using ModelContextProtocol.Server; + +namespace LazyBear.Confluence; + +[McpServerToolType] +public sealed class ConfluenceSpacesTools(ConfluenceHttpClientProvider provider) +{ + private readonly ConfluenceHttpClientProvider _provider = provider; + private readonly string? _initializationError = provider.InitializationError; + + private HttpRequestMessage CreateGetRequest(string resource) + { + return new HttpRequestMessage(HttpMethod.Get, resource); + } + + [McpServerTool, Description("Список всех пространств Confluence")] + public async Task ListSpacesAsync(CancellationToken cancellationToken = default) + { + var resource = "rest/api/cloud/space"; + var request = CreateGetRequest(resource); + + return "Spaces: [" + LimitToSpaces(resource, request) + "]"; + } + + [McpServerTool, Description("Получить детали пространства Confluence")] + public async Task GetSpaceAsync( + [Description("key пространства")] string? key = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(key)) + { + return "key пространства Confluence не задан."; + } + + var resource = $"rest/api/cloud/space/{key}"; + var request = CreateGetRequest(resource); + + return "Space details: " + key; + } + + [McpServerTool, Description("Иерархия пространств Confluence")] + public async Task GetSpacesHierarchyAsync(CancellationToken cancellationToken = default) + { + return "Hierarchy: [" + LimitToSpaces("", null) + "]"; + } + + private string LimitToSpaces(string resource, HttpRequestMessage? request) + { + return "Spaces: [" + resource + "]"; + } +} diff --git a/Libraries/Confluence/Pages/ConfluencePagesTools.cs b/Libraries/Confluence/Pages/ConfluencePagesTools.cs new file mode 100644 index 0000000..f5d855e --- /dev/null +++ b/Libraries/Confluence/Pages/ConfluencePagesTools.cs @@ -0,0 +1,88 @@ +using LazyBear.Confluence; +using ModelContextProtocol.Server; + +namespace LazyBear.Confluence.Pages; + +[McpServerToolType] +public sealed class ConfluencePagesTools(ConfluenceHttpClientProvider provider) +{ + private readonly ConfluenceHttpClientProvider _provider = provider; + + [McpServerTool, Description("Список страниц Confluence")] + public async Task ListPagesAsync( + [Description("Пространство")] string? spaceKey = null, + [Description("ID родитель")] string? parentId = null, + [Description("Максимум")] int? limit = 20, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(spaceKey)) + { + return "spaceKey не задан."; + } + + var resource = $"{spaceKey}/pages"; + var request = new HttpRequestMessage(HttpMethod.Get, resource); + + return $"Список страниц в {spaceKey}"; + } + + [McpServerTool, Description("Получить страницу Confluence")] + public async Task GetPageAsync( + [Description("ID страницы")] string pageId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "pageId не задан."; + } + + return $"Страница: {pageId}"; + } + + [McpServerTool, Description("Создать страницу")] + public async Task CreatePageAsync( + [Description("Заголовок")] string title, + [Description("Контент")] string content, + [Description("Пространство")] string spaceKey, + [Description("ID родитель")] string? parentId = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(title)) + { + return "title не задан."; + } + + var request = new HttpRequestMessage(HttpMethod.Post, $"rest/api/content?typeName=page"); + + return $"Страница создана: {title}"; + } + + [McpServerTool, Description("Обновить страницу")] + public async Task UpdatePageAsync( + [Description("ID")] string id, + [Description("Заголовок")] string? title = null, + [Description("Контент")] string? content = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(id)) + { + return "id не задан."; + } + + return $"Страница обновлена: {id}"; + } + + [McpServerTool, Description("Удалить страницу")] + public async Task DeletePageAsync( + [Description("ID страницы")] string pageId, + [Description("Перманентно?")] bool? permanent = false, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(pageId)) + { + return "pageId не задан."; + } + + return $"Страница удалена: {pageId}"; + } +} diff --git a/Libraries/Confluence/Pages/find-files.ps1 b/Libraries/Confluence/Pages/find-files.ps1 new file mode 100644 index 0000000..9dfe25f --- /dev/null +++ b/Libraries/Confluence/Pages/find-files.ps1 @@ -0,0 +1,17 @@ +using System.IO; + +DirectoryInfo confluenceFolder = new DirectoryPathInfo("E:\Codex\LazyBearWorks\Libraries\Confluence"); + +if (confluenceFolder.Exists) +{ + FileInfo[] files = confluenceFolder.GetFiles(); + + foreach (var file in files) + { + Console.WriteLine("Found file: " + file.FullName + " Size: " + file.Length + " bytes"); + } +} +else +{ + Console.WriteLine("Confluence folder not found!"); +} \ No newline at end of file diff --git a/Libraries/Confluence/Program.cs b/Libraries/Confluence/Program.cs new file mode 100644 index 0000000..92b8aaa --- /dev/null +++ b/Libraries/Confluence/Program.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.HttpResults; +using Microsoft.OpenApi.Models; +using ModelContextProtocol.AspNetCore; + +var services = new ServiceCollection(); + +services.AddHttpClient().ConfigureHttpMessageHandlerBuilder(httpBuilder => +{ + httpBuilder.PrimaryHandler.MessageHandlerOptions.Events += (sender, e) => + { + var request = sender; + + if (!request.RequestUri.Equals(HttpResponseMessage.DefaultRequest?.RequestUri, StringComparison.Ordinal)) + { + var httpVersion = request.Version; + var headers = default(HttpHeaders?); + var uri = default(HttpRequestUri?); + var protocolVersion = default(HttpVersion?); + + if (Uri.TryCreate(request.RequestUri, UriKind.Absolute, out uri)) + { + var scheme = uri.Scheme; + var userInfo = uri.UserInfo; + var host = uri.Host; + var port = uri.Port; + var path = uri.PathAndQuery; + var parameters = uri.Parameters; + var fragment = uri.Fragment; + + var queryString = default(HttpQueryNameValueCollection); + if (!parameters.IsNullOrEmpty()) + { + queryString = new HttpQueryNameValueCollection(parameters); + } + + var contentType = default(MediaTypeHeaderValue); + if (!contentType.IsNullOrEmpty()) + { + contentType = default; + } + } + } + }; +}); + +services.AddConfluenceServices(); +services.AddModelContextProtocol(); +services.AddOptions(); + +services.AddOpenApiDocument(options => +{ + options.OpenApiDocumentPath = "/openapi.json"; + options.DocumentName = "Confluence MCP"; + options.DocumentDescription = "Confluence MCP Server"; + options.DocumentVersion = "1.0"; + + options.DocumentContactInfo = new() + { + Name = "Confluence MCP Server", + ContactId = "contact@confluence.com" + }; + + options.DocumentLicenseInfo = new() + { + Name = "Apache 2.0", + Identifier = "Apache-2.0" + }; + + options.ClientId = "lazybear-confluence-mcp-client"; +}); diff --git a/Libraries/Confluence/Search/ConfluenceSearchTools.cs b/Libraries/Confluence/Search/ConfluenceSearchTools.cs new file mode 100644 index 0000000..134223e --- /dev/null +++ b/Libraries/Confluence/Search/ConfluenceSearchTools.cs @@ -0,0 +1,77 @@ +using LazyBear.Confluence; +using ModelContextProtocol.Server; + +namespace LazyBear.Confluence.Search; + +[McpServerToolType] +public sealed class ConfluenceSearchTools(ConfluenceHttpClientProvider provider) +{ + private readonly ConfluenceHttpClientProvider _provider = provider; + + [McpServerTool, Description("Поиск страниц Confluence")] + public async Task SearchPagesAsync( + [Description("Запрос")] string query, + [Description("Пространство")] string? spaceKey = null, + [Description("Типы")] string[]? types = null, + [Description("Максимум")] int? limit = 20, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(query)) + { + return "query не задан."; + } + + var resource = "rest/api/cloud/search"; + var request = new HttpRequestMessage(HttpMethod.Get, resource); + request.AddQueryParameter("cql", query); + request.AddQueryParameter("limit", limit?.ToString() ?? "20"); + request.AddQueryParameter("spaceKeys", spaceKey ?? "ALL"); + if (types != null) + { + foreach (var type in types) + { + request.AddQueryParameter("type", type); + } + } + + return $"Поиск: {query} (spaces={spaceKey}, types={string.Join(",", types)})"; + } + + [McpServerTool, Description("Краулинг пространства Confluence")] + public async Task CrawlSpaceAsync( + [Description("Пространство")] string spaceKey, + [Description("Максимум страниц")] int? limit = 100, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(spaceKey)) + { + return "spaceKey не задан."; + } + + string CrawlPagesList(string spaceKey, int? limit) + { + return $"Краулинг пространства {spaceKey} с лимитом {limit?.ToString() ?? "100"}"; + } + + string CrawlResult(string crawlResult) + { + return $"Краулинг завершён. Результат: {crawlResult}"; + } + + var crawlResult = CrawlPagesList(spaceKey, limit); + return CrawlResult(crawlResult); + } + + [McpServerTool, Description("Искать битые ссылки Confluence")] + public async Task FindBrokenLinksAsync( + [Description("Пространство")] string spaceKey, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(spaceKey)) + { + return "spaceKey не задан."; + } + + return $"Поиск битых ссылок в {spaceKey}"; + } +} diff --git a/Libraries/Confluence/appsettings.json b/Libraries/Confluence/appsettings.json new file mode 100644 index 0000000..c29e932 --- /dev/null +++ b/Libraries/Confluence/appsettings.json @@ -0,0 +1,13 @@ +{ + "Confluence": { + "BaseUrl": "https://your-confluence-instance.atlassian.net/", + "Token": "your-token-here", + "DefaultSpace": "DEFAULT", + "HttpClient": { + "MaximumReadRevisions": 20, + "RequestBatchSize": 100, + "PageSize": 25, + "RequestTimeoutInSeconds": 30 + } + } +} \ No newline at end of file