From f3964075cc0fc6de9f0d84c62cdb1d2ab5754420 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Mon, 13 Apr 2026 10:44:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20MCP=20=D0=B4=D0=BB=D1=8F=20Jira?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализованы инструменты для задач, статусов и комментариев через Jira REST API. Jira-клиент зарегистрирован в сервере и вынесен в отдельные сервисы. --- LazyBear.MCP/LazyBear.MCP.csproj | 2 + LazyBear.MCP/Program.cs | 2 + .../Services/Jira/JiraClientFactory.cs | 25 + .../Services/Jira/JiraClientProvider.cs | 23 + LazyBear.MCP/Services/Jira/JiraIssueTools.cs | 508 ++++++++++++++++++ LazyBear.MCP/appsettings.json | 5 + 6 files changed, 565 insertions(+) create mode 100644 LazyBear.MCP/Services/Jira/JiraClientFactory.cs create mode 100644 LazyBear.MCP/Services/Jira/JiraClientProvider.cs create mode 100644 LazyBear.MCP/Services/Jira/JiraIssueTools.cs diff --git a/LazyBear.MCP/LazyBear.MCP.csproj b/LazyBear.MCP/LazyBear.MCP.csproj index e2c20cb..9abd8e3 100644 --- a/LazyBear.MCP/LazyBear.MCP.csproj +++ b/LazyBear.MCP/LazyBear.MCP.csproj @@ -11,6 +11,8 @@ + + diff --git a/LazyBear.MCP/Program.cs b/LazyBear.MCP/Program.cs index 99b288f..75639cf 100644 --- a/LazyBear.MCP/Program.cs +++ b/LazyBear.MCP/Program.cs @@ -1,9 +1,11 @@ +using LazyBear.MCP.Services.Jira; using LazyBear.MCP.Services.Kubernetes; using ModelContextProtocol.Server; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithHttpTransport() diff --git a/LazyBear.MCP/Services/Jira/JiraClientFactory.cs b/LazyBear.MCP/Services/Jira/JiraClientFactory.cs new file mode 100644 index 0000000..7405888 --- /dev/null +++ b/LazyBear.MCP/Services/Jira/JiraClientFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Configuration; +using RestSharp; + +namespace LazyBear.MCP.Services.Jira; + +public static class JiraClientFactory +{ + public static RestClient CreateClient(IConfiguration configuration) + { + var jiraUrl = configuration["Jira:Url"] ?? ""; + + if (string.IsNullOrWhiteSpace(jiraUrl)) + { + throw new Exception("Jira:Url не задан"); + } + + var config = new RestClientOptions(jiraUrl) + { + UserAgent = "LazyBear-Jira-MCP", + Timeout = TimeSpan.FromMilliseconds(30000) + }; + + return new RestClient(config); + } +} diff --git a/LazyBear.MCP/Services/Jira/JiraClientProvider.cs b/LazyBear.MCP/Services/Jira/JiraClientProvider.cs new file mode 100644 index 0000000..3302f8b --- /dev/null +++ b/LazyBear.MCP/Services/Jira/JiraClientProvider.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using RestSharp; + +namespace LazyBear.MCP.Services.Jira; + +public sealed class JiraClientProvider +{ + public RestClient? Client { get; } + + public string? InitializationError { get; } + + public JiraClientProvider(IConfiguration configuration) + { + try + { + Client = JiraClientFactory.CreateClient(configuration); + } + catch (Exception ex) + { + InitializationError = $"{ex.GetType().Name}: {ex.Message}"; + } + } +} diff --git a/LazyBear.MCP/Services/Jira/JiraIssueTools.cs b/LazyBear.MCP/Services/Jira/JiraIssueTools.cs new file mode 100644 index 0000000..42d1948 --- /dev/null +++ b/LazyBear.MCP/Services/Jira/JiraIssueTools.cs @@ -0,0 +1,508 @@ +using System.ComponentModel; +using System.Text.Json; +using ModelContextProtocol.Server; +using RestSharp; + +namespace LazyBear.MCP.Services.Jira; + +[McpServerToolType] +public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration configuration) +{ + private readonly RestClient? _client = provider.Client; + private readonly string? _clientInitializationError = provider.InitializationError; + private readonly string _token = configuration["Jira:Token"] ?? string.Empty; + private readonly string _defaultProject = configuration["Jira:Project"] ?? string.Empty; + + [McpServerTool, Description("Получить задачу Jira по ключу")] + public async Task GetIssue( + [Description("Ключ задачи, например PROJ-123")] string issueKey, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(issueKey)) + { + return "Ключ задачи Jira не задан."; + } + + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + try + { + var request = CreateRequest($"/rest/api/3/issue/{issueKey}"); + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("get_issue", response, issueKey); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + var summary = GetNestedString(root, "fields", "summary") ?? "-"; + var status = GetNestedString(root, "fields", "status", "name") ?? "-"; + var issueType = GetNestedString(root, "fields", "issuetype", "name") ?? "-"; + var assignee = GetNestedString(root, "fields", "assignee", "displayName") ?? "unassigned"; + + return $"Задача '{issueKey}': summary={summary}, status={status}, type={issueType}, assignee={assignee}"; + } + catch (Exception ex) + { + return FormatException("get_issue", ex, issueKey); + } + } + + [McpServerTool, Description("Список задач Jira по JQL")] + public async Task ListIssues( + [Description("JQL запрос. Если пусто, используется проект по умолчанию")] string? jql = null, + [Description("Максимум задач в ответе")] int maxResults = 20, + CancellationToken cancellationToken = default) + { + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + var resolvedJql = ResolveJql(jql); + if (string.IsNullOrWhiteSpace(resolvedJql)) + { + return "JQL не задан и Jira:Project не настроен."; + } + + try + { + var request = CreateRequest("/rest/api/3/search/jql"); + request.AddQueryParameter("jql", resolvedJql); + request.AddQueryParameter("maxResults", Math.Max(1, maxResults).ToString()); + request.AddQueryParameter("fields", "summary,status,issuetype"); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("list_issues", response, resolvedJql); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + if (!root.TryGetProperty("issues", out var issuesElement) || issuesElement.GetArrayLength() == 0) + { + return "Задачи Jira не найдены."; + } + + var lines = new List(); + foreach (var issue in issuesElement.EnumerateArray()) + { + var key = issue.TryGetProperty("key", out var keyElement) ? keyElement.GetString() ?? "unknown" : "unknown"; + var summary = GetNestedString(issue, "fields", "summary") ?? "-"; + var status = GetNestedString(issue, "fields", "status", "name") ?? "-"; + lines.Add($"{key}: {summary} [{status}]"); + } + + return $"Задачи Jira по JQL '{resolvedJql}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatException("list_issues", ex, resolvedJql); + } + } + + [McpServerTool, Description("Создать задачу Jira")] + public async Task CreateIssue( + [Description("Summary задачи")] string summary, + [Description("Ключ проекта. Если пусто, используется Jira:Project")] string? projectKey = null, + [Description("Тип задачи, например Task или Bug")] string issueType = "Task", + [Description("Описание задачи")] string? description = null, + CancellationToken cancellationToken = default) + { + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + var resolvedProject = string.IsNullOrWhiteSpace(projectKey) ? _defaultProject : projectKey; + if (string.IsNullOrWhiteSpace(resolvedProject)) + { + return "Проект Jira не задан. Укажите projectKey или настройте Jira:Project."; + } + + try + { + var request = CreateRequest("/rest/api/3/issue", Method.Post); + request.AddJsonBody(new + { + fields = new + { + project = new { key = resolvedProject }, + summary, + issuetype = new { name = issueType }, + description = string.IsNullOrWhiteSpace(description) + ? null + : new + { + type = "doc", + version = 1, + content = new object[] + { + new + { + type = "paragraph", + content = new object[] + { + new + { + type = "text", + text = description + } + } + } + } + } + } + }); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("create_issue", response, resolvedProject); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + var createdKey = root.TryGetProperty("key", out var keyElement) ? keyElement.GetString() ?? "-" : "-"; + + return $"Задача Jira создана: {createdKey}"; + } + catch (Exception ex) + { + return FormatException("create_issue", ex, resolvedProject); + } + } + + [McpServerTool, Description("Обновить summary или описание задачи Jira")] + public async Task UpdateIssue( + [Description("Ключ задачи")] string issueKey, + [Description("Новый summary")] string? summary = null, + [Description("Новое описание")] string? description = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description)) + { + return $"Нет полей для обновления задачи '{issueKey}'."; + } + + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + try + { + var fields = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(summary)) + { + fields["summary"] = summary; + } + + if (!string.IsNullOrWhiteSpace(description)) + { + fields["description"] = new + { + type = "doc", + version = 1, + content = new object[] + { + new + { + type = "paragraph", + content = new object[] + { + new + { + type = "text", + text = description + } + } + } + } + }; + } + + var request = CreateRequest($"/rest/api/3/issue/{issueKey}", Method.Put); + request.AddJsonBody(new { fields }); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful) + { + return FormatResponseError("update_issue", response, issueKey); + } + + return $"Задача Jira '{issueKey}' обновлена."; + } + catch (Exception ex) + { + return FormatException("update_issue", ex, issueKey); + } + } + + [McpServerTool, Description("Доступные переходы статуса для задачи Jira")] + public async Task GetIssueStatuses( + [Description("Ключ задачи")] string issueKey, + CancellationToken cancellationToken = default) + { + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + try + { + var request = CreateRequest($"/rest/api/3/issue/{issueKey}/transitions"); + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("get_issue_statuses", response, issueKey); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + if (!root.TryGetProperty("transitions", out var transitionsElement) || transitionsElement.GetArrayLength() == 0) + { + return $"Для задачи '{issueKey}' нет доступных переходов."; + } + + var lines = new List(); + foreach (var transition in transitionsElement.EnumerateArray()) + { + var name = transition.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "-" : "-"; + var targetStatus = GetNestedString(transition, "to", "name") ?? "-"; + lines.Add($"{name} -> {targetStatus}"); + } + + return $"Переходы статуса для '{issueKey}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatException("get_issue_statuses", ex, issueKey); + } + } + + [McpServerTool, Description("Список комментариев задачи Jira")] + public async Task ListIssueComments( + [Description("Ключ задачи")] string issueKey, + [Description("Максимум комментариев")] int limit = 20, + CancellationToken cancellationToken = default) + { + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + try + { + var request = CreateRequest($"/rest/api/3/issue/{issueKey}/comment"); + request.AddQueryParameter("maxResults", Math.Max(1, limit).ToString()); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("list_issue_comments", response, issueKey); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + + if (!root.TryGetProperty("comments", out var commentsElement) || commentsElement.GetArrayLength() == 0) + { + return $"Комментарии к задаче '{issueKey}' отсутствуют."; + } + + var lines = new List(); + foreach (var comment in commentsElement.EnumerateArray()) + { + var id = comment.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? "-" : "-"; + var author = GetNestedString(comment, "author", "displayName") ?? "unknown"; + var text = ExtractCommentText(comment.GetProperty("body")); + lines.Add($"{id}: {author} -> {text}"); + } + + return $"Комментарии задачи '{issueKey}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatException("list_issue_comments", ex, issueKey); + } + } + + [McpServerTool, Description("Добавить комментарий к задаче Jira")] + public async Task AddComment( + [Description("Ключ задачи")] string issueKey, + [Description("Текст комментария")] string body, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(body)) + { + return "Текст комментария Jira не задан."; + } + + if (!TryGetClient(out var client, out var error)) + { + return error; + } + + try + { + var request = CreateRequest($"/rest/api/3/issue/{issueKey}/comment", Method.Post); + request.AddJsonBody(new + { + body = new + { + type = "doc", + version = 1, + content = new object[] + { + new + { + type = "paragraph", + content = new object[] + { + new + { + type = "text", + text = body + } + } + } + } + } + }); + + var response = await client.ExecuteAsync(request, cancellationToken); + + if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content)) + { + return FormatResponseError("add_comment", response, issueKey); + } + + using var document = JsonDocument.Parse(response.Content); + var root = document.RootElement; + var commentId = root.TryGetProperty("id", out var idElement) ? idElement.GetString() ?? "-" : "-"; + + return $"Комментарий добавлен к задаче '{issueKey}', id={commentId}."; + } + catch (Exception ex) + { + return FormatException("add_comment", ex, issueKey); + } + } + + private string? ResolveJql(string? jql) + { + if (!string.IsNullOrWhiteSpace(jql)) + { + return jql; + } + + return string.IsNullOrWhiteSpace(_defaultProject) ? null : $"project = {_defaultProject} ORDER BY updated DESC"; + } + + private RestRequest CreateRequest(string resource, Method method = Method.Get) + { + var request = new RestRequest(resource, method); + request.AddHeader("Accept", "application/json"); + + 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 = "Jira клиент не инициализирован. Проверьте Jira: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 string ExtractCommentText(JsonElement body) + { + var chunks = new List(); + CollectText(body, chunks); + return chunks.Count == 0 ? "-" : string.Join(" ", chunks); + } + + private static void CollectText(JsonElement element, List chunks) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty("text", out var textElement) && textElement.ValueKind == JsonValueKind.String) + { + chunks.Add(textElement.GetString() ?? string.Empty); + } + + foreach (var property in element.EnumerateObject()) + { + CollectText(property.Value, chunks); + } + + return; + } + + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CollectText(item, chunks); + } + } + } + + 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 $"Ошибка Jira в 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 $"Ошибка Jira в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}"; + } +} diff --git a/LazyBear.MCP/appsettings.json b/LazyBear.MCP/appsettings.json index f279c46..51bf340 100644 --- a/LazyBear.MCP/appsettings.json +++ b/LazyBear.MCP/appsettings.json @@ -3,6 +3,11 @@ "KubeconfigPath": "", "DefaultNamespace": "default" }, + "Jira": { + "Url": "", + "Token": "", + "Project": "" + }, "Logging": { "LogLevel": { "Default": "Information",