kubectl-mcp-server 1.14.0__py3-none-any.whl → 1.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kubectl_mcp_server-1.16.0.dist-info/METADATA +1047 -0
- kubectl_mcp_server-1.16.0.dist-info/RECORD +61 -0
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/crd_detector.py +247 -0
- kubectl_mcp_tool/k8s_config.py +304 -63
- kubectl_mcp_tool/mcp_server.py +27 -0
- kubectl_mcp_tool/tools/__init__.py +20 -0
- kubectl_mcp_tool/tools/backup.py +881 -0
- kubectl_mcp_tool/tools/capi.py +727 -0
- kubectl_mcp_tool/tools/certs.py +709 -0
- kubectl_mcp_tool/tools/cilium.py +582 -0
- kubectl_mcp_tool/tools/cluster.py +395 -121
- kubectl_mcp_tool/tools/core.py +157 -60
- kubectl_mcp_tool/tools/cost.py +97 -41
- kubectl_mcp_tool/tools/deployments.py +173 -56
- kubectl_mcp_tool/tools/diagnostics.py +40 -13
- kubectl_mcp_tool/tools/gitops.py +552 -0
- kubectl_mcp_tool/tools/helm.py +133 -46
- kubectl_mcp_tool/tools/keda.py +464 -0
- kubectl_mcp_tool/tools/kiali.py +652 -0
- kubectl_mcp_tool/tools/kubevirt.py +803 -0
- kubectl_mcp_tool/tools/networking.py +106 -32
- kubectl_mcp_tool/tools/operations.py +176 -50
- kubectl_mcp_tool/tools/pods.py +162 -50
- kubectl_mcp_tool/tools/policy.py +554 -0
- kubectl_mcp_tool/tools/rollouts.py +790 -0
- kubectl_mcp_tool/tools/security.py +89 -36
- kubectl_mcp_tool/tools/storage.py +35 -16
- tests/test_browser.py +2 -2
- tests/test_ecosystem.py +331 -0
- tests/test_tools.py +73 -10
- kubectl_mcp_server-1.14.0.dist-info/METADATA +0 -780
- kubectl_mcp_server-1.14.0.dist-info/RECORD +0 -49
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.14.0.dist-info → kubectl_mcp_server-1.16.0.dist-info}/top_level.txt +0 -0
|
@@ -3,6 +3,12 @@ from typing import Any, Dict, List, Optional
|
|
|
3
3
|
|
|
4
4
|
from mcp.types import ToolAnnotations
|
|
5
5
|
|
|
6
|
+
from ..k8s_config import (
|
|
7
|
+
get_k8s_client,
|
|
8
|
+
get_rbac_client,
|
|
9
|
+
get_networking_client,
|
|
10
|
+
)
|
|
11
|
+
|
|
6
12
|
logger = logging.getLogger("mcp-server")
|
|
7
13
|
|
|
8
14
|
|
|
@@ -15,12 +21,18 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
15
21
|
readOnlyHint=True,
|
|
16
22
|
),
|
|
17
23
|
)
|
|
18
|
-
def get_rbac_roles(
|
|
19
|
-
|
|
24
|
+
def get_rbac_roles(
|
|
25
|
+
namespace: Optional[str] = None,
|
|
26
|
+
context: str = ""
|
|
27
|
+
) -> Dict[str, Any]:
|
|
28
|
+
"""Get RBAC Roles in a namespace or cluster-wide.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
namespace: Namespace to list roles from (all namespaces if not specified)
|
|
32
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
33
|
+
"""
|
|
20
34
|
try:
|
|
21
|
-
|
|
22
|
-
config.load_kube_config()
|
|
23
|
-
rbac = client.RbacAuthorizationV1Api()
|
|
35
|
+
rbac = get_rbac_client(context)
|
|
24
36
|
|
|
25
37
|
if namespace:
|
|
26
38
|
roles = rbac.list_namespaced_role(namespace)
|
|
@@ -29,6 +41,7 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
29
41
|
|
|
30
42
|
return {
|
|
31
43
|
"success": True,
|
|
44
|
+
"context": context or "current",
|
|
32
45
|
"roles": [
|
|
33
46
|
{
|
|
34
47
|
"name": role.metadata.name,
|
|
@@ -55,16 +68,19 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
55
68
|
readOnlyHint=True,
|
|
56
69
|
),
|
|
57
70
|
)
|
|
58
|
-
def get_cluster_roles() -> Dict[str, Any]:
|
|
59
|
-
"""Get ClusterRoles in the cluster.
|
|
71
|
+
def get_cluster_roles(context: str = "") -> Dict[str, Any]:
|
|
72
|
+
"""Get ClusterRoles in the cluster.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
76
|
+
"""
|
|
60
77
|
try:
|
|
61
|
-
|
|
62
|
-
config.load_kube_config()
|
|
63
|
-
rbac = client.RbacAuthorizationV1Api()
|
|
78
|
+
rbac = get_rbac_client(context)
|
|
64
79
|
roles = rbac.list_cluster_role()
|
|
65
80
|
|
|
66
81
|
return {
|
|
67
82
|
"success": True,
|
|
83
|
+
"context": context or "current",
|
|
68
84
|
"clusterRoles": [
|
|
69
85
|
{
|
|
70
86
|
"name": role.metadata.name,
|
|
@@ -90,12 +106,18 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
90
106
|
readOnlyHint=True,
|
|
91
107
|
),
|
|
92
108
|
)
|
|
93
|
-
def analyze_pod_security(
|
|
94
|
-
|
|
109
|
+
def analyze_pod_security(
|
|
110
|
+
namespace: Optional[str] = None,
|
|
111
|
+
context: str = ""
|
|
112
|
+
) -> Dict[str, Any]:
|
|
113
|
+
"""Analyze pod security configurations.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
namespace: Namespace to analyze pods in (all namespaces if not specified)
|
|
117
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
118
|
+
"""
|
|
95
119
|
try:
|
|
96
|
-
|
|
97
|
-
config.load_kube_config()
|
|
98
|
-
v1 = client.CoreV1Api()
|
|
120
|
+
v1 = get_k8s_client(context)
|
|
99
121
|
|
|
100
122
|
if namespace:
|
|
101
123
|
pods = v1.list_namespaced_pod(namespace)
|
|
@@ -133,6 +155,7 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
133
155
|
|
|
134
156
|
return {
|
|
135
157
|
"success": True,
|
|
158
|
+
"context": context or "current",
|
|
136
159
|
"totalPods": len(pods.items),
|
|
137
160
|
"podsWithIssues": len(issues),
|
|
138
161
|
"issues": issues[:50]
|
|
@@ -147,13 +170,19 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
147
170
|
readOnlyHint=True,
|
|
148
171
|
),
|
|
149
172
|
)
|
|
150
|
-
def analyze_network_policies(
|
|
151
|
-
|
|
173
|
+
def analyze_network_policies(
|
|
174
|
+
namespace: Optional[str] = None,
|
|
175
|
+
context: str = ""
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""Analyze network policies in the cluster.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
namespace: Namespace to analyze policies in (all namespaces if not specified)
|
|
181
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
182
|
+
"""
|
|
152
183
|
try:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
networking = client.NetworkingV1Api()
|
|
156
|
-
v1 = client.CoreV1Api()
|
|
184
|
+
networking = get_networking_client(context)
|
|
185
|
+
v1 = get_k8s_client(context)
|
|
157
186
|
|
|
158
187
|
if namespace:
|
|
159
188
|
policies = networking.list_namespaced_network_policy(namespace)
|
|
@@ -174,6 +203,7 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
174
203
|
|
|
175
204
|
return {
|
|
176
205
|
"success": True,
|
|
206
|
+
"context": context or "current",
|
|
177
207
|
"totalPolicies": len(policies.items),
|
|
178
208
|
"protectedNamespaces": list(protected_namespaces),
|
|
179
209
|
"unprotectedNamespaces": unprotected,
|
|
@@ -197,12 +227,20 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
197
227
|
readOnlyHint=True,
|
|
198
228
|
),
|
|
199
229
|
)
|
|
200
|
-
def audit_rbac_permissions(
|
|
201
|
-
|
|
230
|
+
def audit_rbac_permissions(
|
|
231
|
+
namespace: Optional[str] = None,
|
|
232
|
+
subject: Optional[str] = None,
|
|
233
|
+
context: str = ""
|
|
234
|
+
) -> Dict[str, Any]:
|
|
235
|
+
"""Audit RBAC permissions for subjects.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
namespace: Namespace to audit (cluster-wide if not specified)
|
|
239
|
+
subject: Filter by subject name
|
|
240
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
241
|
+
"""
|
|
202
242
|
try:
|
|
203
|
-
|
|
204
|
-
config.load_kube_config()
|
|
205
|
-
rbac = client.RbacAuthorizationV1Api()
|
|
243
|
+
rbac = get_rbac_client(context)
|
|
206
244
|
|
|
207
245
|
cluster_bindings = rbac.list_cluster_role_binding()
|
|
208
246
|
if namespace:
|
|
@@ -238,6 +276,7 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
238
276
|
|
|
239
277
|
return {
|
|
240
278
|
"success": True,
|
|
279
|
+
"context": context or "current",
|
|
241
280
|
"permissions": permissions[:100]
|
|
242
281
|
}
|
|
243
282
|
except Exception as e:
|
|
@@ -250,12 +289,18 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
250
289
|
readOnlyHint=True,
|
|
251
290
|
),
|
|
252
291
|
)
|
|
253
|
-
def check_secrets_security(
|
|
254
|
-
|
|
292
|
+
def check_secrets_security(
|
|
293
|
+
namespace: Optional[str] = None,
|
|
294
|
+
context: str = ""
|
|
295
|
+
) -> Dict[str, Any]:
|
|
296
|
+
"""Check security posture of secrets.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
namespace: Namespace to check secrets in (all namespaces if not specified)
|
|
300
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
301
|
+
"""
|
|
255
302
|
try:
|
|
256
|
-
|
|
257
|
-
config.load_kube_config()
|
|
258
|
-
v1 = client.CoreV1Api()
|
|
303
|
+
v1 = get_k8s_client(context)
|
|
259
304
|
|
|
260
305
|
if namespace:
|
|
261
306
|
secrets = v1.list_namespaced_secret(namespace)
|
|
@@ -282,6 +327,7 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
282
327
|
|
|
283
328
|
return {
|
|
284
329
|
"success": True,
|
|
330
|
+
"context": context or "current",
|
|
285
331
|
"totalSecrets": len(secrets.items),
|
|
286
332
|
"secretsWithIssues": len(findings),
|
|
287
333
|
"findings": findings[:50]
|
|
@@ -296,12 +342,18 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
296
342
|
readOnlyHint=True,
|
|
297
343
|
),
|
|
298
344
|
)
|
|
299
|
-
def get_pod_security_info(
|
|
300
|
-
|
|
345
|
+
def get_pod_security_info(
|
|
346
|
+
namespace: Optional[str] = None,
|
|
347
|
+
context: str = ""
|
|
348
|
+
) -> Dict[str, Any]:
|
|
349
|
+
"""Get Pod Security Standards information for namespaces.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
namespace: Namespace to check (all namespaces if not specified)
|
|
353
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
354
|
+
"""
|
|
301
355
|
try:
|
|
302
|
-
|
|
303
|
-
config.load_kube_config()
|
|
304
|
-
v1 = client.CoreV1Api()
|
|
356
|
+
v1 = get_k8s_client(context)
|
|
305
357
|
|
|
306
358
|
if namespace:
|
|
307
359
|
namespaces = [v1.read_namespace(namespace)]
|
|
@@ -325,6 +377,7 @@ def register_security_tools(server, non_destructive: bool):
|
|
|
325
377
|
|
|
326
378
|
return {
|
|
327
379
|
"success": True,
|
|
380
|
+
"context": context or "current",
|
|
328
381
|
"note": "Pod Security Policies are deprecated. Using Pod Security Standards (PSS) labels.",
|
|
329
382
|
"namespacesWithPSS": result
|
|
330
383
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from typing import Any, Dict, Optional
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
3
|
|
|
4
4
|
from mcp.types import ToolAnnotations
|
|
5
5
|
|
|
6
|
+
from ..k8s_config import get_k8s_client, get_storage_client
|
|
7
|
+
|
|
6
8
|
logger = logging.getLogger("mcp-server")
|
|
7
9
|
|
|
8
10
|
|
|
@@ -15,12 +17,18 @@ def register_storage_tools(server, non_destructive: bool):
|
|
|
15
17
|
readOnlyHint=True,
|
|
16
18
|
),
|
|
17
19
|
)
|
|
18
|
-
def get_persistent_volumes(
|
|
19
|
-
|
|
20
|
+
def get_persistent_volumes(
|
|
21
|
+
name: Optional[str] = None,
|
|
22
|
+
context: str = ""
|
|
23
|
+
) -> Dict[str, Any]:
|
|
24
|
+
"""Get Persistent Volumes in the cluster.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Specific PV name to get (all PVs if not specified)
|
|
28
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
29
|
+
"""
|
|
20
30
|
try:
|
|
21
|
-
|
|
22
|
-
config.load_kube_config()
|
|
23
|
-
v1 = client.CoreV1Api()
|
|
31
|
+
v1 = get_k8s_client(context)
|
|
24
32
|
|
|
25
33
|
if name:
|
|
26
34
|
pv = v1.read_persistent_volume(name)
|
|
@@ -39,6 +47,7 @@ def register_storage_tools(server, non_destructive: bool):
|
|
|
39
47
|
|
|
40
48
|
return {
|
|
41
49
|
"success": True,
|
|
50
|
+
"context": context or "current",
|
|
42
51
|
"persistentVolumes": [
|
|
43
52
|
{
|
|
44
53
|
"name": pv.metadata.name,
|
|
@@ -66,12 +75,18 @@ def register_storage_tools(server, non_destructive: bool):
|
|
|
66
75
|
readOnlyHint=True,
|
|
67
76
|
),
|
|
68
77
|
)
|
|
69
|
-
def get_pvcs(
|
|
70
|
-
|
|
78
|
+
def get_pvcs(
|
|
79
|
+
namespace: Optional[str] = None,
|
|
80
|
+
context: str = ""
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
"""Get Persistent Volume Claims in a namespace or cluster-wide.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
namespace: Namespace to list PVCs from (all namespaces if not specified)
|
|
86
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
87
|
+
"""
|
|
71
88
|
try:
|
|
72
|
-
|
|
73
|
-
config.load_kube_config()
|
|
74
|
-
v1 = client.CoreV1Api()
|
|
89
|
+
v1 = get_k8s_client(context)
|
|
75
90
|
|
|
76
91
|
if namespace:
|
|
77
92
|
pvcs = v1.list_namespaced_persistent_volume_claim(namespace)
|
|
@@ -80,6 +95,7 @@ def register_storage_tools(server, non_destructive: bool):
|
|
|
80
95
|
|
|
81
96
|
return {
|
|
82
97
|
"success": True,
|
|
98
|
+
"context": context or "current",
|
|
83
99
|
"pvcs": [
|
|
84
100
|
{
|
|
85
101
|
"name": pvc.metadata.name,
|
|
@@ -103,17 +119,20 @@ def register_storage_tools(server, non_destructive: bool):
|
|
|
103
119
|
readOnlyHint=True,
|
|
104
120
|
),
|
|
105
121
|
)
|
|
106
|
-
def get_storage_classes() -> Dict[str, Any]:
|
|
107
|
-
"""Get Storage Classes in the cluster.
|
|
122
|
+
def get_storage_classes(context: str = "") -> Dict[str, Any]:
|
|
123
|
+
"""Get Storage Classes in the cluster.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
context: Kubernetes context to use (uses current context if not specified)
|
|
127
|
+
"""
|
|
108
128
|
try:
|
|
109
|
-
|
|
110
|
-
config.load_kube_config()
|
|
111
|
-
storage = client.StorageV1Api()
|
|
129
|
+
storage = get_storage_client(context)
|
|
112
130
|
|
|
113
131
|
scs = storage.list_storage_class()
|
|
114
132
|
|
|
115
133
|
return {
|
|
116
134
|
"success": True,
|
|
135
|
+
"context": context or "current",
|
|
117
136
|
"storageClasses": [
|
|
118
137
|
{
|
|
119
138
|
"name": sc.metadata.name,
|
tests/test_browser.py
CHANGED
|
@@ -501,11 +501,11 @@ class TestServerIntegration:
|
|
|
501
501
|
tools = asyncio.run(server.server.list_tools())
|
|
502
502
|
tool_names = [t.name for t in tools]
|
|
503
503
|
|
|
504
|
-
# Should have browser tools (
|
|
504
|
+
# Should have browser tools (224 + 26 = 250)
|
|
505
505
|
assert "browser_open" in tool_names
|
|
506
506
|
assert "browser_screenshot" in tool_names
|
|
507
507
|
assert "browser_connect_cdp" in tool_names # v0.7 tool
|
|
508
|
-
assert len(tools) ==
|
|
508
|
+
assert len(tools) == 250, f"Expected 250 tools (224 + 26), got {len(tools)}"
|
|
509
509
|
|
|
510
510
|
|
|
511
511
|
import asyncio
|
tests/test_ecosystem.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for ecosystem tools (GitOps, Cert-Manager, Policy, Backup).
|
|
3
|
+
|
|
4
|
+
This module tests the CRD detector and all ecosystem toolsets.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from unittest.mock import patch, MagicMock
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCRDDetector:
|
|
13
|
+
"""Tests for the CRD auto-discovery framework."""
|
|
14
|
+
|
|
15
|
+
@pytest.mark.unit
|
|
16
|
+
def test_crd_detector_imports(self):
|
|
17
|
+
"""Test that CRD detector module can be imported."""
|
|
18
|
+
from kubectl_mcp_tool.crd_detector import (
|
|
19
|
+
CRD_GROUPS,
|
|
20
|
+
detect_crds,
|
|
21
|
+
crd_exists,
|
|
22
|
+
get_enabled_toolsets,
|
|
23
|
+
get_crd_status_summary,
|
|
24
|
+
FeatureNotInstalledError,
|
|
25
|
+
require_crd,
|
|
26
|
+
require_any_crd,
|
|
27
|
+
)
|
|
28
|
+
assert CRD_GROUPS is not None
|
|
29
|
+
assert callable(detect_crds)
|
|
30
|
+
assert callable(crd_exists)
|
|
31
|
+
assert callable(get_enabled_toolsets)
|
|
32
|
+
assert callable(get_crd_status_summary)
|
|
33
|
+
assert issubclass(FeatureNotInstalledError, Exception)
|
|
34
|
+
assert callable(require_crd)
|
|
35
|
+
assert callable(require_any_crd)
|
|
36
|
+
|
|
37
|
+
@pytest.mark.unit
|
|
38
|
+
def test_crd_groups_structure(self):
|
|
39
|
+
"""Test that CRD_GROUPS has expected structure."""
|
|
40
|
+
from kubectl_mcp_tool.crd_detector import CRD_GROUPS
|
|
41
|
+
|
|
42
|
+
expected_groups = ["flux", "argocd", "certmanager", "kyverno", "gatekeeper", "velero"]
|
|
43
|
+
for group in expected_groups:
|
|
44
|
+
assert group in CRD_GROUPS, f"Expected group '{group}' in CRD_GROUPS"
|
|
45
|
+
assert isinstance(CRD_GROUPS[group], list), f"CRD_GROUPS['{group}'] should be a list"
|
|
46
|
+
assert len(CRD_GROUPS[group]) > 0, f"CRD_GROUPS['{group}'] should not be empty"
|
|
47
|
+
|
|
48
|
+
@pytest.mark.unit
|
|
49
|
+
def test_detect_crds_with_mocked_kubectl(self):
|
|
50
|
+
"""Test CRD detection with mocked kubectl."""
|
|
51
|
+
from kubectl_mcp_tool.crd_detector import detect_crds, _crd_cache
|
|
52
|
+
|
|
53
|
+
# Clear cache before test
|
|
54
|
+
_crd_cache.clear()
|
|
55
|
+
|
|
56
|
+
mock_output = """NAME CREATED AT
|
|
57
|
+
applications.argoproj.io 2024-01-01T00:00:00Z
|
|
58
|
+
certificates.cert-manager.io 2024-01-01T00:00:00Z
|
|
59
|
+
"""
|
|
60
|
+
with patch("subprocess.run") as mock_run:
|
|
61
|
+
mock_run.return_value = MagicMock(
|
|
62
|
+
returncode=0,
|
|
63
|
+
stdout=mock_output
|
|
64
|
+
)
|
|
65
|
+
result = detect_crds(force_refresh=True)
|
|
66
|
+
|
|
67
|
+
assert isinstance(result, dict)
|
|
68
|
+
assert "argocd" in result
|
|
69
|
+
assert "certmanager" in result
|
|
70
|
+
|
|
71
|
+
@pytest.mark.unit
|
|
72
|
+
def test_detect_crds_handles_kubectl_failure(self):
|
|
73
|
+
"""Test CRD detection handles kubectl failure gracefully."""
|
|
74
|
+
from kubectl_mcp_tool.crd_detector import detect_crds, _crd_cache
|
|
75
|
+
|
|
76
|
+
_crd_cache.clear()
|
|
77
|
+
|
|
78
|
+
with patch("subprocess.run") as mock_run:
|
|
79
|
+
mock_run.side_effect = subprocess.SubprocessError("Command failed")
|
|
80
|
+
result = detect_crds(force_refresh=True)
|
|
81
|
+
|
|
82
|
+
assert isinstance(result, dict)
|
|
83
|
+
# All groups should be False when kubectl fails
|
|
84
|
+
for value in result.values():
|
|
85
|
+
assert value is False
|
|
86
|
+
|
|
87
|
+
@pytest.mark.unit
|
|
88
|
+
def test_feature_not_installed_error(self):
|
|
89
|
+
"""Test FeatureNotInstalledError exception."""
|
|
90
|
+
from kubectl_mcp_tool.crd_detector import FeatureNotInstalledError
|
|
91
|
+
|
|
92
|
+
error = FeatureNotInstalledError("velero", ["backups.velero.io"])
|
|
93
|
+
assert "velero" in str(error)
|
|
94
|
+
assert "backups.velero.io" in str(error)
|
|
95
|
+
assert error.toolset == "velero"
|
|
96
|
+
assert "backups.velero.io" in error.required_crds
|
|
97
|
+
|
|
98
|
+
@pytest.mark.unit
|
|
99
|
+
def test_get_crd_status_summary(self):
|
|
100
|
+
"""Test CRD status summary generation."""
|
|
101
|
+
from kubectl_mcp_tool.crd_detector import get_crd_status_summary, _crd_cache
|
|
102
|
+
|
|
103
|
+
_crd_cache.clear()
|
|
104
|
+
|
|
105
|
+
with patch("subprocess.run") as mock_run:
|
|
106
|
+
mock_run.return_value = MagicMock(
|
|
107
|
+
returncode=0,
|
|
108
|
+
stdout="applications.argoproj.io 2024-01-01T00:00:00Z\n"
|
|
109
|
+
)
|
|
110
|
+
summary = get_crd_status_summary()
|
|
111
|
+
|
|
112
|
+
# Summary returns a dict with crd_groups and enabled_toolsets
|
|
113
|
+
assert isinstance(summary, dict)
|
|
114
|
+
assert "crd_groups" in summary
|
|
115
|
+
assert "enabled_toolsets" in summary
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestGitOpsTools:
|
|
119
|
+
"""Tests for GitOps toolset (Flux and ArgoCD)."""
|
|
120
|
+
|
|
121
|
+
@pytest.mark.unit
|
|
122
|
+
def test_gitops_tools_import(self):
|
|
123
|
+
"""Test that GitOps tools can be imported."""
|
|
124
|
+
from kubectl_mcp_tool.tools.gitops import register_gitops_tools
|
|
125
|
+
assert callable(register_gitops_tools)
|
|
126
|
+
|
|
127
|
+
@pytest.mark.unit
|
|
128
|
+
def test_gitops_tools_register(self, mock_all_kubernetes_apis):
|
|
129
|
+
"""Test that GitOps tools register correctly."""
|
|
130
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
131
|
+
import asyncio
|
|
132
|
+
|
|
133
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
134
|
+
server = MCPServer(name="test")
|
|
135
|
+
|
|
136
|
+
async def get_tools():
|
|
137
|
+
return await server.server.list_tools()
|
|
138
|
+
|
|
139
|
+
tools = asyncio.run(get_tools())
|
|
140
|
+
tool_names = {t.name for t in tools}
|
|
141
|
+
|
|
142
|
+
gitops_tools = [
|
|
143
|
+
"gitops_apps_list_tool", "gitops_app_get_tool", "gitops_app_sync_tool",
|
|
144
|
+
"gitops_app_status_tool", "gitops_sources_list_tool", "gitops_source_get_tool",
|
|
145
|
+
"gitops_detect_engine_tool"
|
|
146
|
+
]
|
|
147
|
+
for tool in gitops_tools:
|
|
148
|
+
assert tool in tool_names, f"GitOps tool '{tool}' not registered"
|
|
149
|
+
|
|
150
|
+
@pytest.mark.unit
|
|
151
|
+
def test_gitops_non_destructive_mode(self, mock_all_kubernetes_apis):
|
|
152
|
+
"""Test that GitOps sync is blocked in non-destructive mode."""
|
|
153
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
154
|
+
|
|
155
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
156
|
+
server = MCPServer(name="test", non_destructive=True)
|
|
157
|
+
|
|
158
|
+
# Server should initialize with non_destructive=True
|
|
159
|
+
assert server.non_destructive is True
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TestCertManagerTools:
|
|
163
|
+
"""Tests for Cert-Manager toolset."""
|
|
164
|
+
|
|
165
|
+
@pytest.mark.unit
|
|
166
|
+
def test_certs_tools_import(self):
|
|
167
|
+
"""Test that Cert-Manager tools can be imported."""
|
|
168
|
+
from kubectl_mcp_tool.tools.certs import register_certs_tools
|
|
169
|
+
assert callable(register_certs_tools)
|
|
170
|
+
|
|
171
|
+
@pytest.mark.unit
|
|
172
|
+
def test_certs_tools_register(self, mock_all_kubernetes_apis):
|
|
173
|
+
"""Test that Cert-Manager tools register correctly."""
|
|
174
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
175
|
+
import asyncio
|
|
176
|
+
|
|
177
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
178
|
+
server = MCPServer(name="test")
|
|
179
|
+
|
|
180
|
+
async def get_tools():
|
|
181
|
+
return await server.server.list_tools()
|
|
182
|
+
|
|
183
|
+
tools = asyncio.run(get_tools())
|
|
184
|
+
tool_names = {t.name for t in tools}
|
|
185
|
+
|
|
186
|
+
certs_tools = [
|
|
187
|
+
"certs_list_tool", "certs_get_tool", "certs_issuers_list_tool", "certs_issuer_get_tool",
|
|
188
|
+
"certs_renew_tool", "certs_status_explain_tool", "certs_challenges_list_tool",
|
|
189
|
+
"certs_requests_list_tool", "certs_detect_tool"
|
|
190
|
+
]
|
|
191
|
+
for tool in certs_tools:
|
|
192
|
+
assert tool in tool_names, f"Cert-Manager tool '{tool}' not registered"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestPolicyTools:
|
|
196
|
+
"""Tests for Policy toolset (Kyverno and Gatekeeper)."""
|
|
197
|
+
|
|
198
|
+
@pytest.mark.unit
|
|
199
|
+
def test_policy_tools_import(self):
|
|
200
|
+
"""Test that Policy tools can be imported."""
|
|
201
|
+
from kubectl_mcp_tool.tools.policy import register_policy_tools
|
|
202
|
+
assert callable(register_policy_tools)
|
|
203
|
+
|
|
204
|
+
@pytest.mark.unit
|
|
205
|
+
def test_policy_tools_register(self, mock_all_kubernetes_apis):
|
|
206
|
+
"""Test that Policy tools register correctly."""
|
|
207
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
208
|
+
import asyncio
|
|
209
|
+
|
|
210
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
211
|
+
server = MCPServer(name="test")
|
|
212
|
+
|
|
213
|
+
async def get_tools():
|
|
214
|
+
return await server.server.list_tools()
|
|
215
|
+
|
|
216
|
+
tools = asyncio.run(get_tools())
|
|
217
|
+
tool_names = {t.name for t in tools}
|
|
218
|
+
|
|
219
|
+
policy_tools = [
|
|
220
|
+
"policy_list_tool", "policy_get_tool", "policy_violations_list_tool",
|
|
221
|
+
"policy_explain_denial_tool", "policy_audit_tool", "policy_detect_tool"
|
|
222
|
+
]
|
|
223
|
+
for tool in policy_tools:
|
|
224
|
+
assert tool in tool_names, f"Policy tool '{tool}' not registered"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestBackupTools:
|
|
228
|
+
"""Tests for Backup toolset (Velero)."""
|
|
229
|
+
|
|
230
|
+
@pytest.mark.unit
|
|
231
|
+
def test_backup_tools_import(self):
|
|
232
|
+
"""Test that Backup tools can be imported."""
|
|
233
|
+
from kubectl_mcp_tool.tools.backup import register_backup_tools
|
|
234
|
+
assert callable(register_backup_tools)
|
|
235
|
+
|
|
236
|
+
@pytest.mark.unit
|
|
237
|
+
def test_backup_tools_register(self, mock_all_kubernetes_apis):
|
|
238
|
+
"""Test that Backup tools register correctly."""
|
|
239
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
240
|
+
import asyncio
|
|
241
|
+
|
|
242
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
243
|
+
server = MCPServer(name="test")
|
|
244
|
+
|
|
245
|
+
async def get_tools():
|
|
246
|
+
return await server.server.list_tools()
|
|
247
|
+
|
|
248
|
+
tools = asyncio.run(get_tools())
|
|
249
|
+
tool_names = {t.name for t in tools}
|
|
250
|
+
|
|
251
|
+
backup_tools = [
|
|
252
|
+
"backup_list_tool", "backup_get_tool", "backup_create_tool", "backup_delete_tool",
|
|
253
|
+
"restore_list_tool", "restore_create_tool", "restore_get_tool",
|
|
254
|
+
"backup_locations_list_tool", "backup_schedules_list_tool",
|
|
255
|
+
"backup_schedule_create_tool", "backup_detect_tool"
|
|
256
|
+
]
|
|
257
|
+
for tool in backup_tools:
|
|
258
|
+
assert tool in tool_names, f"Backup tool '{tool}' not registered"
|
|
259
|
+
|
|
260
|
+
@pytest.mark.unit
|
|
261
|
+
def test_backup_non_destructive_mode(self, mock_all_kubernetes_apis):
|
|
262
|
+
"""Test that backup operations are blocked in non-destructive mode."""
|
|
263
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
264
|
+
|
|
265
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
266
|
+
server = MCPServer(name="test", non_destructive=True)
|
|
267
|
+
|
|
268
|
+
# Server should initialize with non_destructive=True
|
|
269
|
+
assert server.non_destructive is True
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class TestEcosystemToolsIntegration:
|
|
273
|
+
"""Integration tests for ecosystem tools."""
|
|
274
|
+
|
|
275
|
+
@pytest.mark.unit
|
|
276
|
+
def test_all_ecosystem_tools_have_descriptions(self, mock_all_kubernetes_apis):
|
|
277
|
+
"""Test that all ecosystem tools have descriptions."""
|
|
278
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
279
|
+
import asyncio
|
|
280
|
+
|
|
281
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
282
|
+
server = MCPServer(name="test")
|
|
283
|
+
|
|
284
|
+
async def get_tools():
|
|
285
|
+
return await server.server.list_tools()
|
|
286
|
+
|
|
287
|
+
tools = asyncio.run(get_tools())
|
|
288
|
+
|
|
289
|
+
ecosystem_prefixes = ["gitops_", "certs_", "policy_", "backup_", "restore_"]
|
|
290
|
+
ecosystem_tools = [t for t in tools if any(t.name.startswith(p) for p in ecosystem_prefixes)]
|
|
291
|
+
|
|
292
|
+
tools_without_description = [
|
|
293
|
+
t.name for t in ecosystem_tools
|
|
294
|
+
if not t.description or len(t.description.strip()) == 0
|
|
295
|
+
]
|
|
296
|
+
assert not tools_without_description, f"Ecosystem tools without descriptions: {tools_without_description}"
|
|
297
|
+
|
|
298
|
+
@pytest.mark.unit
|
|
299
|
+
def test_ecosystem_tool_count(self, mock_all_kubernetes_apis):
|
|
300
|
+
"""Test that correct number of ecosystem tools are registered."""
|
|
301
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
302
|
+
import asyncio
|
|
303
|
+
|
|
304
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
|
|
305
|
+
server = MCPServer(name="test")
|
|
306
|
+
|
|
307
|
+
async def get_tools():
|
|
308
|
+
return await server.server.list_tools()
|
|
309
|
+
|
|
310
|
+
tools = asyncio.run(get_tools())
|
|
311
|
+
|
|
312
|
+
# Filter ecosystem tools, but exclude backup_resource which is in operations.py
|
|
313
|
+
ecosystem_tool_names = [
|
|
314
|
+
"gitops_apps_list_tool", "gitops_app_get_tool", "gitops_app_sync_tool",
|
|
315
|
+
"gitops_app_status_tool", "gitops_sources_list_tool", "gitops_source_get_tool",
|
|
316
|
+
"gitops_detect_engine_tool",
|
|
317
|
+
"certs_list_tool", "certs_get_tool", "certs_issuers_list_tool", "certs_issuer_get_tool",
|
|
318
|
+
"certs_renew_tool", "certs_status_explain_tool", "certs_challenges_list_tool",
|
|
319
|
+
"certs_requests_list_tool", "certs_detect_tool",
|
|
320
|
+
"policy_list_tool", "policy_get_tool", "policy_violations_list_tool",
|
|
321
|
+
"policy_explain_denial_tool", "policy_audit_tool", "policy_detect_tool",
|
|
322
|
+
"backup_list_tool", "backup_get_tool", "backup_create_tool", "backup_delete_tool",
|
|
323
|
+
"restore_list_tool", "restore_create_tool", "restore_get_tool",
|
|
324
|
+
"backup_locations_list_tool", "backup_schedules_list_tool",
|
|
325
|
+
"backup_schedule_create_tool", "backup_detect_tool"
|
|
326
|
+
]
|
|
327
|
+
tool_names = {t.name for t in tools}
|
|
328
|
+
ecosystem_tools = [name for name in ecosystem_tool_names if name in tool_names]
|
|
329
|
+
|
|
330
|
+
# 7 GitOps + 9 Certs + 6 Policy + 11 Backup = 33 ecosystem tools
|
|
331
|
+
assert len(ecosystem_tools) == 33, f"Expected 33 ecosystem tools, got {len(ecosystem_tools)}"
|