diff --git a/AGENTS.md b/AGENTS.md index 3afb05f..b01650f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,245 +1,35 @@ -## AGENTS.md +# AGENTS.md -### PRIORITY +## Scope -1. User request -2. This file -3. Existing code +- `LazyBearWorks.sln` builds exactly one project: `LazyBear.MCP/`. `Libraries/Confluence/` is present in the repo but is not part of the solution and has no `.csproj`; ignore it unless the user explicitly asks for it. ---- +## Source Of Truth -## CODE +- Trust `LazyBear.MCP/Program.cs` and project config over `README.md`. The README describes Razor Pages/UI and a broader tool surface, but the current app only configures MCP HTTP transport and auto-registers tool classes from the assembly. -### STACK +## Entry Points -* .NET / C# -* ASP.NET Core -* MCP +- Main app entrypoint: `LazyBear.MCP/Program.cs`. +- Live MCP tools are discovered via `AddMcpServer().WithHttpTransport().WithToolsFromAssembly()`. Current tool classes are under `LazyBear.MCP/Services/Kubernetes/` and `LazyBear.MCP/Services/Jira/` and use `[McpServerToolType]`. +- Do not assume Razor Pages are active just because `Pages/` exists: `Program.cs` does not call `AddRazorPages()` or `MapRazorPages()`. -### STRUCTURE +## Commands -* `Server/` — endpoints -* `Services/` — business logic -* `Tools/` — MCP tools +- Build from repo root: `dotnet build` +- Build project directly: `dotnet build "LazyBear.MCP/LazyBear.MCP.csproj"` +- Run from repo root: `dotnet run --project "LazyBear.MCP"` +- MCP manual check: `npx @modelcontextprotocol/inspector dotnet run --project LazyBear.MCP` +- There are no test projects in the solution right now; default verification is `dotnet build`, plus a focused run/inspector check for transport or tool-registration changes. -### RULES +## Runtime Quirks -**Before edit** +- `LazyBear.MCP/Properties/launchSettings.json` says `http://localhost:5079`, but the app actually listens on `http://localhost:5000` because `Program.cs` hardcodes `app.Run("http://localhost:5000")`. +- SDK pin is in `LazyBear.MCP/global.json` (`10.0.100`), not at the repo root. -* Read related code -* Reuse existing patterns -* Do not over-engineer +## Config Gotchas -**After edit** - -* Run: - - ``` - dotnet build - ``` -* Build must succeed -* Do not break MCP protocol -* Keep diff minimal - -**Style** - -* Match existing style -* Avoid duplication -* Prefer small changes - ---- - -## COMMUNICATION - -### LANGUAGE - -* Output: Russian -* Code: English -* Comments/commits: Russian - -### BEHAVIOR - -* Be concise -* Do not explain obvious things -* Do not produce long texts -* Prefer action when no clarification is needed - ---- - -### QUESTIONS - -Default: - -* If result can be improved by user choice → ASK FIRST -* Do not execute immediately if preferences affect result - -Ask BEFORE action if: - -* Multiple valid directions exist -* Result depends on user preference -* Request is broad (e.g. "suggest", "recommend", "generate") - -Otherwise: - -* Proceed with best reasonable assumption - ---- - -### QUESTION TOOL - -`question` is the UI tool for user choices in OpenCode. - -Use `question` BEFORE answering when: - -* 2+ meaningful options exist -* clarification improves result quality -* choice affects architecture, config, data, or output - -Do NOT skip `question` in these cases. - -Do NOT use when: - -* request is already specific -* only one valid answer exists -* clarification does not change result - -If unavailable: - -* ask in plain text - ---- - -### RESTRICTIONS - -* Do not end with only a question -* Do not expose secrets -* Do not repeat user text - ---- - -## TOOLS - -Always assume tools MAY be available. - -Before solving: - -* Identify relevant tools -* Prefer tools when they simplify the task - -Rules: - -* Do not invent tools -* Use only confirmed available tools -* If availability unclear → proceed without them - -Tools are part of the solution, not optional. - ---- - -## MEMORY - -Use ONLY if memory tools are available. - -### READ FIRST - -Before coding or assumptions: - -1. Try `read_graph` -2. Then `search_nodes()` -3. Avoid duplicate observations - -If unavailable: - -* Skip memory usage - -### KEY FORMAT - -```text -lazybear// -``` - -Examples: - -```text -lazybear/bug/auth-fail -lazybear/decision/mcp-timeout -lazybear/config/jira-base-url -``` - -### ALLOWED TYPES - -* `architecture` -* `mcp_tool` -* `decision` -* `bug` -* `config` -* `task_log` - -### WRITE ONLY WHEN USEFUL - -* architecture changes → `architecture` -* new MCP tools → `mcp_tool` -* major decisions → `decision` -* important bugs → `bug` -* config changes → `config` -* completed non-trivial tasks → `task_log` - -### RULES - -* One entity = one type -* Keep entries short -* Do not duplicate -* Skip trivial changes - ---- - -## SECRETS - -* Never print secrets -* Never commit `.env.local` - -Use: - -* `.env.local` → runtime -* `.env.example` → reference - ---- - -## LINKS - -Internal: - -* Relative paths -* Spaces → `%20` - -External: - -* Markdown links only - ---- - -## EDITING RULES - -* Do not modify this file unless asked -* Do not change structure -* Keep instructions short and explicit - ---- - -## CORE BEHAVIOR - -* Ask first if it improves result quality - -* Otherwise act - -* Always consider tools before solving - -* Prefer tools when useful - -* Minimal changes only - -* Do not invent tools - -* Use tools only if confirmed available - -* Never leak secrets +- App config lives in `LazyBear.MCP/appsettings.json`. +- `Kubernetes:KubeconfigPath` defaults empty. `K8sClientFactory` then tries the default kubeconfig, then in-cluster config. +- Jira tools require `Jira:Url`; if it is empty, `JiraClientFactory` throws during provider setup. The app still starts because providers capture init errors and tools return those errors as strings. +- Treat `appsettings.json` values as potentially sensitive; do not commit real Jira tokens or cluster-specific settings. diff --git a/LazyBear.MCP/LazyBear.MCP.csproj b/LazyBear.MCP/LazyBear.MCP.csproj index 9abd8e3..8f664b2 100644 --- a/LazyBear.MCP/LazyBear.MCP.csproj +++ b/LazyBear.MCP/LazyBear.MCP.csproj @@ -9,8 +9,10 @@ + + diff --git a/LazyBear.MCP/Services/Jira/JiraClientFactory.cs b/LazyBear.MCP/Services/Jira/JiraClientFactory.cs index 7405888..9772dac 100644 --- a/LazyBear.MCP/Services/Jira/JiraClientFactory.cs +++ b/LazyBear.MCP/Services/Jira/JiraClientFactory.cs @@ -1,10 +1,25 @@ using Microsoft.Extensions.Configuration; +using Polly; using RestSharp; namespace LazyBear.MCP.Services.Jira; public static class JiraClientFactory { + private static readonly TimeSpan[] BackoffDurations = { + TimeSpan.FromMilliseconds(1000), + TimeSpan.FromMilliseconds(2000), + TimeSpan.FromMilliseconds(4000) + }; + + private static readonly Policy _retryPolicy = Policy + .HandleResult(response => !response.IsSuccessful && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .Or() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: attempt => BackoffDurations[attempt], + onRetry: (outcome, timespan, attempt, context) => { }); + public static RestClient CreateClient(IConfiguration configuration) { var jiraUrl = configuration["Jira:Url"] ?? ""; @@ -20,6 +35,6 @@ public static class JiraClientFactory Timeout = TimeSpan.FromMilliseconds(30000) }; - return new RestClient(config); + return _retryPolicy.Wrap(new RestClient(config)); } } diff --git a/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs b/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs index a2e0185..53b2cfe 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sConfigTools.cs @@ -6,17 +6,16 @@ using ModelContextProtocol.Server; namespace LazyBear.MCP.Services.Kubernetes; [McpServerToolType] -public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration) +public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) { - private readonly IKubernetes? _client = clientProvider.Client; - private readonly string? _clientInitializationError = clientProvider.InitializationError; - private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + private const int MaxSecretKeyLimit = 100; [McpServerTool, Description("Список ConfigMap в namespace")] public async Task ListConfigMaps( [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -53,6 +52,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -82,6 +83,7 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -118,6 +120,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -157,49 +161,4 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat 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 index 1e443ec..8d2b506 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sDeploymentTools.cs @@ -8,17 +8,17 @@ using ModelContextProtocol.Server; namespace LazyBear.MCP.Services.Kubernetes; [McpServerToolType] -public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration) +public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) { - private readonly IKubernetes? _client = clientProvider.Client; - private readonly string? _clientInitializationError = clientProvider.InitializationError; - private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + private const int MinReplicas = 0; + private const int MaxReplicas = 100; [McpServerTool, Description("Список deployment в namespace")] public async Task ListDeployments( [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -58,6 +58,14 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); + + if (replicas < MinReplicas || replicas > MaxReplicas) + { + return $"Invalid replicas value: {replicas}. Must be between {MinReplicas} and {MaxReplicas}."; + } + var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -87,6 +95,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -116,6 +126,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -151,49 +163,4 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig 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 index edcb9aa..870462d 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sNetworkTools.cs @@ -6,17 +6,14 @@ using ModelContextProtocol.Server; namespace LazyBear.MCP.Services.Kubernetes; [McpServerToolType] -public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration) +public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) { - 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) { + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -58,6 +55,8 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -91,6 +90,7 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -136,49 +136,4 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura 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 index 53dbd9e..a0fc12a 100644 --- a/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs +++ b/LazyBear.MCP/Services/Kubernetes/K8sPodsTools.cs @@ -6,17 +6,17 @@ using ModelContextProtocol.Server; namespace LazyBear.MCP.Services.Kubernetes; [McpServerToolType] -public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration) +public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger) { - private readonly IKubernetes? _client = clientProvider.Client; - private readonly string? _clientInitializationError = clientProvider.InitializationError; - private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + private const int MaxTailLines = 10; + private const int MinTailLines = 10; [McpServerTool, Description("Список подов в namespace")] public async Task ListPods( [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -55,6 +55,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio [Description("Namespace Kubernetes")] string? @namespace = null, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -88,6 +90,18 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio int? tailLines = 100, CancellationToken cancellationToken = default) { + ValidateResourceName(name, nameof(name)); + ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace)); + + if (tailLines < MinTailLines) + { + tailLines = MinTailLines; + } + if (tailLines > MaxTailLines) + { + tailLines = MaxTailLines; + } + var ns = ResolveNamespace(@namespace); if (!TryGetClient(out var client, out var clientError)) { @@ -100,7 +114,7 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio name, ns, container: container, - tailLines: tailLines, + tailLines: (int?)tailLines, cancellationToken: cancellationToken); using var reader = new StreamReader(logStream); @@ -118,49 +132,4 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio 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/Services/Kubernetes/KubernetesToolsBase.cs b/LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs new file mode 100644 index 0000000..896771e --- /dev/null +++ b/LazyBear.MCP/Services/Kubernetes/KubernetesToolsBase.cs @@ -0,0 +1,107 @@ +using System.ComponentModel; +using System.Text.RegularExpressions; +using k8s; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace LazyBear.MCP.Services.Kubernetes; + +[McpServerToolType] +public abstract class KubernetesToolsBase(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null) +{ + protected readonly IKubernetes? _client = clientProvider.Client; + protected readonly string? _clientInitializationError = clientProvider.InitializationError; + protected readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default"; + protected readonly ILogger? _logger = logger; + + protected static readonly Regex NamespaceRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + protected static readonly Regex ResourceNameRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + protected string ResolveNamespace(string? @namespace) + { + return string.IsNullOrWhiteSpace(@namespace) ? _defaultNamespace : @namespace; + } + + protected void ValidateNamespace(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Namespace не может быть пустым", parameterName); + } + if (value.Length > 63) + { + throw new ArgumentException($"Namespace не может быть длиннее 63 символов", parameterName); + } + if (!NamespaceRegex.IsMatch(value)) + { + throw new ArgumentException($"Невалидный формат namespace: '{value}'. Только lowercase alphanumetic, -, не может начинаться/заканчиваться на -", parameterName); + } + } + + protected void ValidateResourceName(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Resource name не может быть пустым", parameterName); + } + if (value.Length > 253) + { + throw new ArgumentException($"Resource name не может быть длиннее 253 символов", parameterName); + } + if (!ResourceNameRegex.IsMatch(value)) + { + throw new ArgumentException($"Невалидный формат: '{value}'. Только lowercase alphanumetic, -, не может начинаться/заканчиваться на -", parameterName); + } + } + + protected bool TryGetClient(out IKubernetes client, out string error) + { + if (_client is null) + { + client = null!; + var details = string.IsNullOrWhiteSpace(_clientInitializationError) + ? string.Empty + : $" Детали: {_clientInitializationError}"; + error = "Kubernetes клиент не инициализирован. Проверьте Kubernetes:KubeconfigPath или in-cluster окружение (KUBERNETES_SERVICE_HOST/KUBERNETES_SERVICE_PORT)." + details; + return false; + } + + client = _client; + error = string.Empty; + return true; + } + + protected string FormatError(string toolName, string @namespace, Exception exception, string? resourceName = null) + { + _logger?.LogError(exception, "Ошибка Kubernetes в tool '{ToolName}' (namespace='{Namespace}'{ResourcePart}): {ExceptionMessage}", + toolName, @namespace, + string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'", + exception.Message); + + var resourcePart = string.IsNullOrWhiteSpace(resourceName) ? string.Empty : $", resource='{resourceName}'"; + + if (exception is global::k8s.Autorest.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}"; + } + + protected string FormatException(string toolName, Exception exception, string? resource = null) + { + _logger?.LogError(exception, "Ошибка exception в tool '{ToolName}'{ResourcePart}: {ExceptionMessage}", + toolName, + string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'", + exception.Message); + + var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'"; + return $"Ошибка в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}"; + } +} diff --git a/__DONT_USE.md b/__DONT_USE.md new file mode 100644 index 0000000..3afb05f --- /dev/null +++ b/__DONT_USE.md @@ -0,0 +1,245 @@ +## AGENTS.md + +### PRIORITY + +1. User request +2. This file +3. Existing code + +--- + +## CODE + +### STACK + +* .NET / C# +* ASP.NET Core +* MCP + +### STRUCTURE + +* `Server/` — endpoints +* `Services/` — business logic +* `Tools/` — MCP tools + +### RULES + +**Before edit** + +* Read related code +* Reuse existing patterns +* Do not over-engineer + +**After edit** + +* Run: + + ``` + dotnet build + ``` +* Build must succeed +* Do not break MCP protocol +* Keep diff minimal + +**Style** + +* Match existing style +* Avoid duplication +* Prefer small changes + +--- + +## COMMUNICATION + +### LANGUAGE + +* Output: Russian +* Code: English +* Comments/commits: Russian + +### BEHAVIOR + +* Be concise +* Do not explain obvious things +* Do not produce long texts +* Prefer action when no clarification is needed + +--- + +### QUESTIONS + +Default: + +* If result can be improved by user choice → ASK FIRST +* Do not execute immediately if preferences affect result + +Ask BEFORE action if: + +* Multiple valid directions exist +* Result depends on user preference +* Request is broad (e.g. "suggest", "recommend", "generate") + +Otherwise: + +* Proceed with best reasonable assumption + +--- + +### QUESTION TOOL + +`question` is the UI tool for user choices in OpenCode. + +Use `question` BEFORE answering when: + +* 2+ meaningful options exist +* clarification improves result quality +* choice affects architecture, config, data, or output + +Do NOT skip `question` in these cases. + +Do NOT use when: + +* request is already specific +* only one valid answer exists +* clarification does not change result + +If unavailable: + +* ask in plain text + +--- + +### RESTRICTIONS + +* Do not end with only a question +* Do not expose secrets +* Do not repeat user text + +--- + +## TOOLS + +Always assume tools MAY be available. + +Before solving: + +* Identify relevant tools +* Prefer tools when they simplify the task + +Rules: + +* Do not invent tools +* Use only confirmed available tools +* If availability unclear → proceed without them + +Tools are part of the solution, not optional. + +--- + +## MEMORY + +Use ONLY if memory tools are available. + +### READ FIRST + +Before coding or assumptions: + +1. Try `read_graph` +2. Then `search_nodes()` +3. Avoid duplicate observations + +If unavailable: + +* Skip memory usage + +### KEY FORMAT + +```text +lazybear// +``` + +Examples: + +```text +lazybear/bug/auth-fail +lazybear/decision/mcp-timeout +lazybear/config/jira-base-url +``` + +### ALLOWED TYPES + +* `architecture` +* `mcp_tool` +* `decision` +* `bug` +* `config` +* `task_log` + +### WRITE ONLY WHEN USEFUL + +* architecture changes → `architecture` +* new MCP tools → `mcp_tool` +* major decisions → `decision` +* important bugs → `bug` +* config changes → `config` +* completed non-trivial tasks → `task_log` + +### RULES + +* One entity = one type +* Keep entries short +* Do not duplicate +* Skip trivial changes + +--- + +## SECRETS + +* Never print secrets +* Never commit `.env.local` + +Use: + +* `.env.local` → runtime +* `.env.example` → reference + +--- + +## LINKS + +Internal: + +* Relative paths +* Spaces → `%20` + +External: + +* Markdown links only + +--- + +## EDITING RULES + +* Do not modify this file unless asked +* Do not change structure +* Keep instructions short and explicit + +--- + +## CORE BEHAVIOR + +* Ask first if it improves result quality + +* Otherwise act + +* Always consider tools before solving + +* Prefer tools when useful + +* Minimal changes only + +* Do not invent tools + +* Use tools only if confirmed available + +* Never leak secrets diff --git a/opencode.json b/opencode.json index 66b431b..22281ab 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1,5 @@ { "$schema": "https://opencode.ai/config.json", - "model": "ollama/qwen3.5-agent" + "model": "ollama/qwen3.5-agent", + "instructions": [ "AGENTS.md" ] }