diff --git a/LazyBear.MCP/LazyBear.MCP.csproj b/LazyBear.MCP/LazyBear.MCP.csproj index 126d604..e2c20cb 100644 --- a/LazyBear.MCP/LazyBear.MCP.csproj +++ b/LazyBear.MCP/LazyBear.MCP.csproj @@ -7,6 +7,7 @@ + diff --git a/LazyBear.MCP/Program.cs b/LazyBear.MCP/Program.cs index 308d887..99b288f 100644 --- a/LazyBear.MCP/Program.cs +++ b/LazyBear.MCP/Program.cs @@ -1,8 +1,10 @@ +using LazyBear.MCP.Services.Kubernetes; using ModelContextProtocol.Server; -using System.ComponentModel; var builder = WebApplication.CreateBuilder(args); +builder.Services.AddSingleton(); + builder.Services.AddMcpServer() .WithHttpTransport() .WithToolsFromAssembly(); @@ -12,19 +14,3 @@ var app = builder.Build(); app.MapMcp(); app.Run("http://localhost:5000"); - -[McpServerToolType] -public static class TradingTools -{ - [McpServerTool, Description("Получить текущую цену актива")] - public static string GetCurrentPrice([Description("Тикер актива")] string ticker) - { - return $"Цена {ticker}: 50000 USD (фейковые данные)"; - } - - [McpServerTool, Description("Получить информацию о позиции")] - public static string GetPositionInfo([Description("ID позиции")] string positionId) - { - return $"Позиция {positionId}: Long BTC, PnL: +500 USD"; - } -} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sClientFactory.cs b/LazyBear.MCP/Services/Kubernetes/K8sClientFactory.cs new file mode 100644 index 0000000..be6cb4b --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/K8sClientFactory.cs @@ -0,0 +1,33 @@ +using k8s; +using Microsoft.Extensions.Configuration; + +namespace LazyBear.MCP.Services.Kubernetes; + +public static class K8sClientFactory +{ + public static IKubernetes CreateClient(IConfiguration configuration) + { + var kubeconfigPath = configuration["Kubernetes:KubeconfigPath"]; + KubernetesClientConfiguration clientConfiguration; + + if (!string.IsNullOrWhiteSpace(kubeconfigPath)) + { + var expandedPath = Environment.ExpandEnvironmentVariables(kubeconfigPath); + var fullPath = Path.GetFullPath(expandedPath); + + clientConfiguration = KubernetesClientConfiguration.BuildConfigFromConfigFile(fullPath); + return new global::k8s.Kubernetes(clientConfiguration); + } + + try + { + clientConfiguration = KubernetesClientConfiguration.BuildConfigFromConfigFile(); + } + catch + { + clientConfiguration = KubernetesClientConfiguration.InClusterConfig(); + } + + return new global::k8s.Kubernetes(clientConfiguration); + } +} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sClientProvider.cs b/LazyBear.MCP/Services/Kubernetes/K8sClientProvider.cs new file mode 100644 index 0000000..ac7cb1c --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/K8sClientProvider.cs @@ -0,0 +1,23 @@ +using k8s; +using Microsoft.Extensions.Configuration; + +namespace LazyBear.MCP.Services.Kubernetes; + +public sealed class K8sClientProvider +{ + public IKubernetes? Client { get; } + + public string? InitializationError { get; } + + public K8sClientProvider(IConfiguration configuration) + { + try + { + Client = K8sClientFactory.CreateClient(configuration); + } + catch (Exception ex) + { + InitializationError = $"{ex.GetType().Name}: {ex.Message}"; + } + } +} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs new file mode 100644 index 0000000..a2e0185 --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs @@ -0,0 +1,205 @@ +using System.ComponentModel; +using k8s; +using k8s.Autorest; +using ModelContextProtocol.Server; + +namespace LazyBear.MCP.Services.Kubernetes; + +[McpServerToolType] +public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration) +{ + private readonly IKubernetes? _client = clientProvider.Client; + private readonly string? _clientInitializationError = clientProvider.InitializationError; + private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + + [McpServerTool, Description("Список ConfigMap в namespace")] + public async Task ListConfigMaps( + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var configMaps = await client.CoreV1.ListNamespacedConfigMapAsync(ns, cancellationToken: cancellationToken); + + if (configMaps.Items.Count == 0) + { + return $"В namespace '{ns}' configMap не найдены."; + } + + var lines = configMaps.Items.Select(cm => + { + var name = cm.Metadata?.Name ?? "unknown"; + var keyCount = cm.Data?.Count ?? 0; + return $"{name}: keys={keyCount}"; + }); + + return $"ConfigMap namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("list_config_maps", ns, ex); + } + } + + [McpServerTool, Description("Получить данные ConfigMap")] + public async Task GetConfigMapData( + [Description("Имя ConfigMap")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var configMap = await client.CoreV1.ReadNamespacedConfigMapAsync(name, ns, cancellationToken: cancellationToken); + + if (configMap.Data is null || configMap.Data.Count == 0) + { + return $"ConfigMap '{name}' в namespace '{ns}' не содержит данных."; + } + + var lines = configMap.Data.Select(item => $"{item.Key}={item.Value}"); + return $"Данные ConfigMap '{name}' namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("get_config_map_data", ns, ex, name); + } + } + + [McpServerTool, Description("Список Secret в namespace (без значений)")] + public async Task ListSecrets( + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var secrets = await client.CoreV1.ListNamespacedSecretAsync(ns, cancellationToken: cancellationToken); + + if (secrets.Items.Count == 0) + { + return $"В namespace '{ns}' secret не найдены."; + } + + var lines = secrets.Items.Select(secret => + { + var name = secret.Metadata?.Name ?? "unknown"; + var type = secret.Type ?? "Opaque"; + return $"{name}: type={type}"; + }); + + return $"Secret namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("list_secrets", ns, ex); + } + } + + [McpServerTool, Description("Ключи Secret без значений")] + public async Task GetSecretKeys( + [Description("Имя Secret")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var secret = await client.CoreV1.ReadNamespacedSecretAsync(name, ns, cancellationToken: cancellationToken); + + var keys = new HashSet(StringComparer.Ordinal); + if (secret.Data is not null) + { + foreach (var key in secret.Data.Keys) + { + keys.Add(key); + } + } + + if (secret.StringData is not null) + { + foreach (var key in secret.StringData.Keys) + { + keys.Add(key); + } + } + + if (keys.Count == 0) + { + return $"Secret '{name}' в namespace '{ns}' не содержит ключей."; + } + + return $"Ключи Secret '{name}' namespace '{ns}': {string.Join(", ", keys.OrderBy(k => k))}"; + } + catch (Exception ex) + { + return FormatError("get_secret_keys", ns, ex, name); + } + } + + private string ResolveNamespace(string? @namespace) + { + return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace; + } + + private static string BuildClientInitializationError() + { + return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT)."; + } + + private bool TryGetClient(out IKubernetes client, out string error) + { + if (_client is null) + { + client = null!; + var details = string.IsNullOrWhiteSpace(_clientInitializationError) + ? string.Empty + : $" Детали: {_clientInitializationError}"; + error = BuildClientInitializationError() + details; + return false; + } + + client = _client; + error = string.Empty; + return true; + } + + private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null) + { + var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'"; + + if (exception is HttpOperationException httpException) + { + var statusCode = httpException.Response?.StatusCode; + var reason = httpException.Response?.ReasonPhrase; + var body = string.IsNullOrWhiteSpace(httpException.Response?.Content) + ? "-" + : httpException.Response!.Content; + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}"; + } + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}"; + } +} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs new file mode 100644 index 0000000..1e443ec --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs @@ -0,0 +1,199 @@ +using System.ComponentModel; +using System.Text.Json; +using k8s; +using k8s.Autorest; +using k8s.Models; +using ModelContextProtocol.Server; + +namespace LazyBear.MCP.Services.Kubernetes; + +[McpServerToolType] +public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration) +{ + private readonly IKubernetes? _client = clientProvider.Client; + private readonly string? _clientInitializationError = clientProvider.InitializationError; + private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + + [McpServerTool, Description("Список deployment в namespace")] + public async Task ListDeployments( + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var deployments = await client.AppsV1.ListNamespacedDeploymentAsync(ns, cancellationToken: cancellationToken); + + if (deployments.Items.Count == 0) + { + return $"В namespace '{ns}' deployment не найдены."; + } + + var lines = deployments.Items.Select(dep => + { + var name = dep.Metadata?.Name ?? "unknown"; + var desired = dep.Spec?.Replicas ?? 0; + var ready = dep.Status?.ReadyReplicas ?? 0; + var available = dep.Status?.AvailableReplicas ?? 0; + return $"{name}: desired={desired}, ready={ready}, available={available}"; + }); + + return $"Deployment namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("list_deployments", ns, ex); + } + } + + [McpServerTool, Description("Масштабировать deployment")] + public async Task ScaleDeployment( + [Description("Имя deployment")] string name, + [Description("Новое количество реплик")] int replicas, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, ns, cancellationToken: cancellationToken); + + deployment.Spec ??= new V1DeploymentSpec(); + deployment.Spec.Replicas = replicas; + + await client.AppsV1.ReplaceNamespacedDeploymentAsync(deployment, name, ns, cancellationToken: cancellationToken); + + return $"Deployment '{name}' в namespace '{ns}' масштабирован до {replicas} реплик."; + } + catch (Exception ex) + { + return FormatError("scale_deployment", ns, ex, name); + } + } + + [McpServerTool, Description("Статус rollout deployment")] + public async Task GetRolloutStatus( + [Description("Имя deployment")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var deployment = await client.AppsV1.ReadNamespacedDeploymentStatusAsync(name, ns, cancellationToken: cancellationToken); + + var desired = deployment.Spec?.Replicas ?? 0; + var updated = deployment.Status?.UpdatedReplicas ?? 0; + var ready = deployment.Status?.ReadyReplicas ?? 0; + var available = deployment.Status?.AvailableReplicas ?? 0; + + return $"Rollout '{name}' в namespace '{ns}': desired={desired}, updated={updated}, ready={ready}, available={available}"; + } + catch (Exception ex) + { + return FormatError("get_rollout_status", ns, ex, name); + } + } + + [McpServerTool, Description("Перезапустить deployment (rolling restart)")] + public async Task RestartDeployment( + [Description("Имя deployment")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var patchJson = JsonSerializer.Serialize(new + { + spec = new + { + template = new + { + metadata = new + { + annotations = new Dictionary + { + ["kubectl.kubernetes.io/restartedAt"] = DateTimeOffset.UtcNow.ToString("O") + } + } + } + } + }); + + var patch = new V1Patch(patchJson, V1Patch.PatchType.StrategicMergePatch); + await client.AppsV1.PatchNamespacedDeploymentAsync(patch, name, ns, cancellationToken: cancellationToken); + + return $"Deployment '{name}' в namespace '{ns}' отправлен на rolling restart."; + } + catch (Exception ex) + { + return FormatError("restart_deployment", ns, ex, name); + } + } + + private string ResolveNamespace(string? @namespace) + { + return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace; + } + + private static string BuildClientInitializationError() + { + return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT)."; + } + + private bool TryGetClient(out IKubernetes client, out string error) + { + if (_client is null) + { + client = null!; + var details = string.IsNullOrWhiteSpace(_clientInitializationError) + ? string.Empty + : $" Детали: {_clientInitializationError}"; + error = BuildClientInitializationError() + details; + return false; + } + + client = _client; + error = string.Empty; + return true; + } + + private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null) + { + var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'"; + + if (exception is HttpOperationException httpException) + { + var statusCode = httpException.Response?.StatusCode; + var reason = httpException.Response?.ReasonPhrase; + var body = string.IsNullOrWhiteSpace(httpException.Response?.Content) + ? "-" + : httpException.Response!.Content; + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}"; + } + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}"; + } +} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs new file mode 100644 index 0000000..edcb9aa --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs @@ -0,0 +1,184 @@ +using System.ComponentModel; +using k8s; +using k8s.Autorest; +using ModelContextProtocol.Server; + +namespace LazyBear.MCP.Services.Kubernetes; + +[McpServerToolType] +public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration) +{ + private readonly IKubernetes? _client = clientProvider.Client; + private readonly string? _clientInitializationError = clientProvider.InitializationError; + private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + + [McpServerTool, Description("Список service в namespace")] + public async Task ListServices( + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var services = await client.CoreV1.ListNamespacedServiceAsync(ns, cancellationToken: cancellationToken); + + if (services.Items.Count == 0) + { + return $"В namespace '{ns}' service не найдены."; + } + + var lines = services.Items.Select(svc => + { + var name = svc.Metadata?.Name ?? "unknown"; + var type = svc.Spec?.Type ?? "ClusterIP"; + var clusterIp = svc.Spec?.ClusterIP ?? "-"; + var ports = svc.Spec?.Ports is null + ? "-" + : string.Join(",", svc.Spec.Ports.Select(p => $"{p.Port}/{p.Protocol}")); + + return $"{name}: type={type}, clusterIp={clusterIp}, ports={ports}"; + }); + + return $"Services namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("list_services", ns, ex); + } + } + + [McpServerTool, Description("Детали service")] + public async Task GetServiceDetails( + [Description("Имя service")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var service = await client.CoreV1.ReadNamespacedServiceAsync(name, ns, cancellationToken: cancellationToken); + + var type = service.Spec?.Type ?? "ClusterIP"; + var clusterIp = service.Spec?.ClusterIP ?? "-"; + var externalIps = service.Spec?.ExternalIPs is null ? "-" : string.Join(",", service.Spec.ExternalIPs); + var selector = service.Spec?.Selector is null + ? "-" + : string.Join(",", service.Spec.Selector.Select(x => $"{x.Key}={x.Value}")); + var ports = service.Spec?.Ports is null + ? "-" + : string.Join(",", service.Spec.Ports.Select(p => $"{p.Name ?? "port"}:{p.Port}->{p.TargetPort}")); + + return $"Service '{name}' namespace '{ns}': type={type}, clusterIp={clusterIp}, externalIps={externalIps}, selector={selector}, ports={ports}"; + } + catch (Exception ex) + { + return FormatError("get_service_details", ns, ex, name); + } + } + + [McpServerTool, Description("Список ingress в namespace")] + public async Task ListIngresses( + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var ingresses = await client.NetworkingV1.ListNamespacedIngressAsync(ns, cancellationToken: cancellationToken); + + if (ingresses.Items.Count == 0) + { + return $"В namespace '{ns}' ingress не найдены."; + } + + var lines = ingresses.Items.Select(ing => + { + var name = ing.Metadata?.Name ?? "unknown"; + var rules = ing.Spec?.Rules is null + ? "-" + : string.Join(";", ing.Spec.Rules.Select(r => + { + var host = string.IsNullOrWhiteSpace(r.Host) ? "*" : r.Host; + var paths = r.Http?.Paths is null + ? "-" + : string.Join(",", r.Http.Paths.Select(p => + { + var serviceName = p.Backend?.Service?.Name ?? "-"; + var servicePort = p.Backend?.Service?.Port?.Number?.ToString() ?? p.Backend?.Service?.Port?.Name ?? "-"; + return $"{p.Path ?? "/"}->{serviceName}:{servicePort}"; + })); + + return $"{host}:{paths}"; + })); + + return $"{name}: rules={rules}"; + }); + + return $"Ingress namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("list_ingresses", ns, ex); + } + } + + private string ResolveNamespace(string? @namespace) + { + return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace; + } + + private static string BuildClientInitializationError() + { + return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT)."; + } + + private bool TryGetClient(out IKubernetes client, out string error) + { + if (_client is null) + { + client = null!; + var details = string.IsNullOrWhiteSpace(_clientInitializationError) + ? string.Empty + : $" Детали: {_clientInitializationError}"; + error = BuildClientInitializationError() + details; + return false; + } + + client = _client; + error = string.Empty; + return true; + } + + private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null) + { + var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'"; + + if (exception is HttpOperationException httpException) + { + var statusCode = httpException.Response?.StatusCode; + var reason = httpException.Response?.ReasonPhrase; + var body = string.IsNullOrWhiteSpace(httpException.Response?.Content) + ? "-" + : httpException.Response!.Content; + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}"; + } + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}"; + } +} diff --git a/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs new file mode 100644 index 0000000..53dbd9e --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs @@ -0,0 +1,166 @@ +using System.ComponentModel; +using k8s; +using k8s.Autorest; +using ModelContextProtocol.Server; + +namespace LazyBear.MCP.Services.Kubernetes; + +[McpServerToolType] +public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration) +{ + private readonly IKubernetes? _client = clientProvider.Client; + private readonly string? _clientInitializationError = clientProvider.InitializationError; + private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + + [McpServerTool, Description("Список подов в namespace")] + public async Task ListPods( + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var pods = await client.CoreV1.ListNamespacedPodAsync(ns, cancellationToken: cancellationToken); + + if (pods.Items.Count == 0) + { + return $"В namespace '{ns}' поды не найдены."; + } + + var lines = pods.Items.Select(pod => + { + var podName = pod.Metadata?.Name ?? "unknown"; + var phase = pod.Status?.Phase ?? "Unknown"; + var node = pod.Spec?.NodeName ?? "-"; + var restarts = pod.Status?.ContainerStatuses?.Sum(s => s.RestartCount) ?? 0; + return $"{podName}: phase={phase}, restarts={restarts}, node={node}"; + }); + + return $"Поды namespace '{ns}':\n{string.Join('\n', lines)}"; + } + catch (Exception ex) + { + return FormatError("list_pods", ns, ex); + } + } + + [McpServerTool, Description("Статус pod по имени")] + public async Task GetPodStatus( + [Description("Имя pod")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + var pod = await client.CoreV1.ReadNamespacedPodAsync(name, ns, cancellationToken: cancellationToken); + + var phase = pod.Status?.Phase ?? "Unknown"; + var hostIp = pod.Status?.HostIP ?? "-"; + var podIp = pod.Status?.PodIP ?? "-"; + var conditions = pod.Status?.Conditions?.Select(c => $"{c.Type}={c.Status}") ?? []; + + return $"Pod '{name}' в namespace '{ns}': phase={phase}, hostIp={hostIp}, podIp={podIp}, conditions=[{string.Join(", ", conditions)}]"; + } + catch (Exception ex) + { + return FormatError("get_pod_status", ns, ex, name); + } + } + + [McpServerTool, Description("Логи pod")] + public async Task GetPodLogs( + [Description("Имя pod")] string name, + [Description("Namespace Kubernetes")] string? @namespace = null, + [Description("Имя контейнера")] + string? container = null, + [Description("Количество строк с конца")] + int? tailLines = 100, + CancellationToken cancellationToken = default) + { + var ns = ResolveNamespace(@namespace); + if (!TryGetClient(out var client, out var clientError)) + { + return clientError; + } + + try + { + await using var logStream = await client.CoreV1.ReadNamespacedPodLogAsync( + name, + ns, + container: container, + tailLines: tailLines, + cancellationToken: cancellationToken); + + using var reader = new StreamReader(logStream); + var logs = await reader.ReadToEndAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(logs)) + { + return $"Логи pod '{name}' в namespace '{ns}' пустые."; + } + + return logs; + } + catch (Exception ex) + { + return FormatError("get_pod_logs", ns, ex, name); + } + } + + private string ResolveNamespace(string? @namespace) + { + return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace; + } + + private static string BuildClientInitializationError() + { + return "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT)."; + } + + private bool TryGetClient(out IKubernetes client, out string error) + { + if (_client is null) + { + client = null!; + var details = string.IsNullOrWhiteSpace(_clientInitializationError) + ? string.Empty + : $" Детали: {_clientInitializationError}"; + error = BuildClientInitializationError() + details; + return false; + } + + client = _client; + error = string.Empty; + return true; + } + + private static string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null) + { + var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'"; + + if (exception is HttpOperationException httpException) + { + var statusCode = httpException.Response?.StatusCode; + var reason = httpException.Response?.ReasonPhrase; + var body = string.IsNullOrWhiteSpace(httpException.Response?.Content) + ? "-" + : httpException.Response!.Content; + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): status={(int?)statusCode ?? 0} {reason}, details={body}"; + } + + return $"Ошибка Kubernetes в tool '{toolName}' (namespace='{@namespace}'{resourcePart}): {exception.GetType().Name}: {exception.Message}"; + } +} diff --git a/LazyBear.MCP/appsettings.json b/LazyBear.MCP/appsettings.json index ac727af..f279c46 100644 --- a/LazyBear.MCP/appsettings.json +++ b/LazyBear.MCP/appsettings.json @@ -1,4 +1,8 @@ { + "Kubernetes": { + "KubeconfigPath": "", + "DefaultNamespace": "default" + }, "Logging": { "LogLevel": { "Default": "Information",