Add Kubernetes and Jira MCP tools with auto-registration

This commit is contained in:
2026-04-13 14:15:00 +03:00
parent 87fb9e8df8
commit b5eb33272a
10 changed files with 443 additions and 433 deletions

256
AGENTS.md
View File

@@ -1,245 +1,35 @@
## AGENTS.md # AGENTS.md
### PRIORITY ## Scope
1. User request - `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.
2. This file
3. Existing code
--- ## 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# - Main app entrypoint: `LazyBear.MCP/Program.cs`.
* ASP.NET Core - 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]`.
* MCP - Do not assume Razor Pages are active just because `Pages/` exists: `Program.cs` does not call `AddRazorPages()` or `MapRazorPages()`.
### STRUCTURE ## Commands
* `Server/` — endpoints - Build from repo root: `dotnet build`
* `Services/` — business logic - Build project directly: `dotnet build "LazyBear.MCP/LazyBear.MCP.csproj"`
* `Tools/` — MCP tools - 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 ## Config Gotchas
* Reuse existing patterns
* Do not over-engineer
**After edit** - App config lives in `LazyBear.MCP/appsettings.json`.
- `Kubernetes:KubeconfigPath` defaults empty. `K8sClientFactory` then tries the default kubeconfig, then in-cluster config.
* Run: - 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.
```
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/<type>/<name>
```
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

View File

@@ -9,8 +9,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="KubernetesClient" Version="19.0.2" /> <PackageReference Include="KubernetesClient" Version="19.0.2" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" /> <PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" /> <PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" /> <PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="RestSharp" Version="112.0.0" /> <PackageReference Include="RestSharp" Version="112.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> </ItemGroup>

View File

@@ -1,10 +1,25 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Polly;
using RestSharp; using RestSharp;
namespace LazyBear.MCP.Services.Jira; namespace LazyBear.MCP.Services.Jira;
public static class JiraClientFactory 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<RestResponse>(response => !response.IsSuccessful && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.Or<RestException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => BackoffDurations[attempt],
onRetry: (outcome, timespan, attempt, context) => { });
public static RestClient CreateClient(IConfiguration configuration) public static RestClient CreateClient(IConfiguration configuration)
{ {
var jiraUrl = configuration["Jira:Url"] ?? ""; var jiraUrl = configuration["Jira:Url"] ?? "";
@@ -20,6 +35,6 @@ public static class JiraClientFactory
Timeout = TimeSpan.FromMilliseconds(30000) Timeout = TimeSpan.FromMilliseconds(30000)
}; };
return new RestClient(config); return _retryPolicy.Wrap(new RestClient(config));
} }
} }

View File

@@ -6,17 +6,16 @@ using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes; namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType] [McpServerToolType]
public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration) public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sConfigTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
{ {
private readonly IKubernetes? _client = clientProvider.Client; private const int MaxSecretKeyLimit = 100;
private readonly string? _clientInitializationError = clientProvider.InitializationError;
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
[McpServerTool, Description("Список ConfigMap в namespace")] [McpServerTool, Description("Список ConfigMap в namespace")]
public async Task<string> ListConfigMaps( public async Task<string> ListConfigMaps(
[Description("Namespace Kubernetes")] string? @namespace = null, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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); 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}";
}
} }

View File

@@ -8,17 +8,17 @@ using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes; namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType] [McpServerToolType]
public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration) public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sDeploymentTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
{ {
private readonly IKubernetes? _client = clientProvider.Client; private const int MinReplicas = 0;
private readonly string? _clientInitializationError = clientProvider.InitializationError; private const int MaxReplicas = 100;
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
[McpServerTool, Description("Список deployment в namespace")] [McpServerTool, Description("Список deployment в namespace")]
public async Task<string> ListDeployments( public async Task<string> ListDeployments(
[Description("Namespace Kubernetes")] string? @namespace = null, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) 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); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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); 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}";
}
} }

View File

@@ -6,17 +6,14 @@ using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes; namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType] [McpServerToolType]
public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration) public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sNetworkTools>? 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")] [McpServerTool, Description("Список service в namespace")]
public async Task<string> ListServices( public async Task<string> ListServices(
[Description("Namespace Kubernetes")] string? @namespace = null, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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); 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}";
}
} }

View File

@@ -6,17 +6,17 @@ using ModelContextProtocol.Server;
namespace LazyBear.MCP.Services.Kubernetes; namespace LazyBear.MCP.Services.Kubernetes;
[McpServerToolType] [McpServerToolType]
public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration) public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sPodsTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
{ {
private readonly IKubernetes? _client = clientProvider.Client; private const int MaxTailLines = 10;
private readonly string? _clientInitializationError = clientProvider.InitializationError; private const int MinTailLines = 10;
private readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
[McpServerTool, Description("Список подов в namespace")] [McpServerTool, Description("Список подов в namespace")]
public async Task<string> ListPods( public async Task<string> ListPods(
[Description("Namespace Kubernetes")] string? @namespace = null, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) 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, [Description("Namespace Kubernetes")] string? @namespace = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
ValidateResourceName(name, nameof(name));
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
var ns = ResolveNamespace(@namespace); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) if (!TryGetClient(out var client, out var clientError))
{ {
@@ -88,6 +90,18 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
int? tailLines = 100, int? tailLines = 100,
CancellationToken cancellationToken = default) 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); var ns = ResolveNamespace(@namespace);
if (!TryGetClient(out var client, out var clientError)) if (!TryGetClient(out var client, out var clientError))
{ {
@@ -100,7 +114,7 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
name, name,
ns, ns,
container: container, container: container,
tailLines: tailLines, tailLines: (int?)tailLines,
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
using var reader = new StreamReader(logStream); 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); 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}";
}
} }

View File

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

245
__DONT_USE.md Normal file
View File

@@ -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/<type>/<name>
```
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

View File

@@ -1,4 +1,5 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"model": "ollama/qwen3.5-agent" "model": "ollama/qwen3.5-agent",
"instructions": [ "AGENTS.md" ]
} }