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",
diff --git a/logo_v2.jpg b/logo_v2.jpg
new file mode 100644
index 0000000..d00a84a
Binary files /dev/null and b/logo_v2.jpg differ