Добавлен MCP для Jira

Реализованы инструменты для задач, статусов и комментариев через Jira REST API. Jira-клиент зарегистрирован в сервере и вынесен в отдельные сервисы.
This commit is contained in:
2026-04-13 10:44:45 +03:00
parent d75a08e7d7
commit f3964075cc
6 changed files with 565 additions and 0 deletions

View File

@@ -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<string> 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<string> 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<string>();
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<string> 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<string> 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<string, object?>();
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<string> 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<string>();
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<string> 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<string>();
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<string> 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<string>();
CollectText(body, chunks);
return chunks.Count == 0 ? "-" : string.Join(" ", chunks);
}
private static void CollectText(JsonElement element, List<string> 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}";
}
}