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}"; } }