kubectl-mcp-server 1.15.0__py3-none-any.whl → 1.17.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.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/METADATA +34 -13
- kubectl_mcp_server-1.17.0.dist-info/RECORD +75 -0
- kubectl_mcp_tool/__init__.py +1 -1
- kubectl_mcp_tool/cli/cli.py +83 -9
- kubectl_mcp_tool/cli/output.py +14 -0
- kubectl_mcp_tool/config/__init__.py +46 -0
- kubectl_mcp_tool/config/loader.py +386 -0
- kubectl_mcp_tool/config/schema.py +184 -0
- kubectl_mcp_tool/crd_detector.py +247 -0
- kubectl_mcp_tool/k8s_config.py +19 -0
- kubectl_mcp_tool/mcp_server.py +246 -8
- kubectl_mcp_tool/observability/__init__.py +59 -0
- kubectl_mcp_tool/observability/metrics.py +223 -0
- kubectl_mcp_tool/observability/stats.py +255 -0
- kubectl_mcp_tool/observability/tracing.py +335 -0
- kubectl_mcp_tool/prompts/__init__.py +43 -0
- kubectl_mcp_tool/prompts/builtin.py +695 -0
- kubectl_mcp_tool/prompts/custom.py +298 -0
- kubectl_mcp_tool/prompts/prompts.py +180 -4
- kubectl_mcp_tool/safety.py +155 -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 +384 -0
- kubectl_mcp_tool/tools/gitops.py +552 -0
- 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/policy.py +554 -0
- kubectl_mcp_tool/tools/rollouts.py +790 -0
- tests/test_browser.py +2 -2
- tests/test_config.py +386 -0
- tests/test_ecosystem.py +331 -0
- tests/test_mcp_integration.py +251 -0
- tests/test_observability.py +521 -0
- tests/test_prompts.py +716 -0
- tests/test_safety.py +218 -0
- tests/test_tools.py +70 -8
- kubectl_mcp_server-1.15.0.dist-info/RECORD +0 -49
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/WHEEL +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/entry_points.txt +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/licenses/LICENSE +0 -0
- {kubectl_mcp_server-1.15.0.dist-info → kubectl_mcp_server-1.17.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""CRD Auto-Discovery Framework for kubectl-mcp-server.
|
|
2
|
+
|
|
3
|
+
Detects installed CRDs in the cluster and enables/disables toolsets accordingly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
from typing import Dict, List, Optional, Set
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from .k8s_config import _get_kubectl_context_args
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CRD_GROUPS = {
|
|
16
|
+
"flux": [
|
|
17
|
+
"kustomizations.kustomize.toolkit.fluxcd.io",
|
|
18
|
+
"helmreleases.helm.toolkit.fluxcd.io",
|
|
19
|
+
"gitrepositories.source.toolkit.fluxcd.io",
|
|
20
|
+
"helmrepositories.source.toolkit.fluxcd.io",
|
|
21
|
+
],
|
|
22
|
+
"argocd": [
|
|
23
|
+
"applications.argoproj.io",
|
|
24
|
+
"applicationsets.argoproj.io",
|
|
25
|
+
"appprojects.argoproj.io",
|
|
26
|
+
],
|
|
27
|
+
"certmanager": [
|
|
28
|
+
"certificates.cert-manager.io",
|
|
29
|
+
"issuers.cert-manager.io",
|
|
30
|
+
"clusterissuers.cert-manager.io",
|
|
31
|
+
"certificaterequests.cert-manager.io",
|
|
32
|
+
"orders.acme.cert-manager.io",
|
|
33
|
+
"challenges.acme.cert-manager.io",
|
|
34
|
+
],
|
|
35
|
+
"kyverno": [
|
|
36
|
+
"clusterpolicies.kyverno.io",
|
|
37
|
+
"policies.kyverno.io",
|
|
38
|
+
"policyreports.wgpolicyk8s.io",
|
|
39
|
+
"clusterpolicyreports.wgpolicyk8s.io",
|
|
40
|
+
],
|
|
41
|
+
"gatekeeper": [
|
|
42
|
+
"constrainttemplates.templates.gatekeeper.sh",
|
|
43
|
+
"configs.config.gatekeeper.sh",
|
|
44
|
+
],
|
|
45
|
+
"velero": [
|
|
46
|
+
"backups.velero.io",
|
|
47
|
+
"restores.velero.io",
|
|
48
|
+
"schedules.velero.io",
|
|
49
|
+
"backupstoragelocations.velero.io",
|
|
50
|
+
],
|
|
51
|
+
"keda": [
|
|
52
|
+
"scaledobjects.keda.sh",
|
|
53
|
+
"scaledjobs.keda.sh",
|
|
54
|
+
"triggerauthentications.keda.sh",
|
|
55
|
+
],
|
|
56
|
+
"cilium": [
|
|
57
|
+
"ciliumnetworkpolicies.cilium.io",
|
|
58
|
+
"ciliumclusterwidenetworkpolicies.cilium.io",
|
|
59
|
+
"ciliumendpoints.cilium.io",
|
|
60
|
+
],
|
|
61
|
+
"istio": [
|
|
62
|
+
"virtualservices.networking.istio.io",
|
|
63
|
+
"destinationrules.networking.istio.io",
|
|
64
|
+
"gateways.networking.istio.io",
|
|
65
|
+
],
|
|
66
|
+
"argorollouts": [
|
|
67
|
+
"rollouts.argoproj.io",
|
|
68
|
+
"analysistemplates.argoproj.io",
|
|
69
|
+
],
|
|
70
|
+
"kubevirt": [
|
|
71
|
+
"virtualmachines.kubevirt.io",
|
|
72
|
+
"virtualmachineinstances.kubevirt.io",
|
|
73
|
+
],
|
|
74
|
+
"capi": [
|
|
75
|
+
"clusters.cluster.x-k8s.io",
|
|
76
|
+
"machines.cluster.x-k8s.io",
|
|
77
|
+
"machinedeployments.cluster.x-k8s.io",
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_crd_cache: Dict[str, Dict[str, bool]] = {}
|
|
83
|
+
_cache_timestamp: Dict[str, float] = {}
|
|
84
|
+
CACHE_TTL = 300
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_cluster_crds(context: str = "") -> Set[str]:
|
|
88
|
+
"""Get all CRDs installed in the cluster."""
|
|
89
|
+
try:
|
|
90
|
+
cmd = ["kubectl"] + _get_kubectl_context_args(context) + [
|
|
91
|
+
"get", "crds", "-o", "jsonpath={.items[*].metadata.name}"
|
|
92
|
+
]
|
|
93
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
94
|
+
if result.returncode == 0:
|
|
95
|
+
return set(result.stdout.split())
|
|
96
|
+
return set()
|
|
97
|
+
except Exception:
|
|
98
|
+
return set()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def detect_crds(context: str = "", force_refresh: bool = False) -> Dict[str, bool]:
|
|
102
|
+
"""Detect which CRD groups are installed in the cluster.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
context: Kubernetes context to use
|
|
106
|
+
force_refresh: Force refresh the cache
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Dict mapping CRD group name to installed status
|
|
110
|
+
"""
|
|
111
|
+
cache_key = context or "default"
|
|
112
|
+
|
|
113
|
+
if not force_refresh and cache_key in _crd_cache:
|
|
114
|
+
if time.time() - _cache_timestamp.get(cache_key, 0) < CACHE_TTL:
|
|
115
|
+
return _crd_cache[cache_key]
|
|
116
|
+
|
|
117
|
+
installed_crds = _get_cluster_crds(context)
|
|
118
|
+
|
|
119
|
+
result = {}
|
|
120
|
+
for group_name, crds in CRD_GROUPS.items():
|
|
121
|
+
result[group_name] = any(crd in installed_crds for crd in crds)
|
|
122
|
+
|
|
123
|
+
_crd_cache[cache_key] = result
|
|
124
|
+
_cache_timestamp[cache_key] = time.time()
|
|
125
|
+
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def crd_exists(crd_name: str, context: str = "") -> bool:
|
|
130
|
+
"""Check if a specific CRD exists in the cluster.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
crd_name: Full CRD name (e.g., "certificates.cert-manager.io")
|
|
134
|
+
context: Kubernetes context to use
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
True if CRD exists, False otherwise
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
cmd = ["kubectl"] + _get_kubectl_context_args(context) + [
|
|
141
|
+
"get", "crd", crd_name, "-o", "name"
|
|
142
|
+
]
|
|
143
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
144
|
+
return result.returncode == 0
|
|
145
|
+
except Exception:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_enabled_toolsets(context: str = "") -> List[str]:
|
|
150
|
+
"""Get list of toolsets that should be enabled based on detected CRDs.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
context: Kubernetes context to use
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of enabled toolset names
|
|
157
|
+
"""
|
|
158
|
+
crds = detect_crds(context)
|
|
159
|
+
enabled = []
|
|
160
|
+
|
|
161
|
+
if crds.get("flux") or crds.get("argocd"):
|
|
162
|
+
enabled.append("gitops")
|
|
163
|
+
if crds.get("certmanager"):
|
|
164
|
+
enabled.append("certs")
|
|
165
|
+
if crds.get("kyverno") or crds.get("gatekeeper"):
|
|
166
|
+
enabled.append("policy")
|
|
167
|
+
if crds.get("velero"):
|
|
168
|
+
enabled.append("backup")
|
|
169
|
+
if crds.get("keda"):
|
|
170
|
+
enabled.append("keda")
|
|
171
|
+
if crds.get("cilium"):
|
|
172
|
+
enabled.append("cilium")
|
|
173
|
+
if crds.get("argorollouts"):
|
|
174
|
+
enabled.append("rollouts")
|
|
175
|
+
if crds.get("kubevirt"):
|
|
176
|
+
enabled.append("kubevirt")
|
|
177
|
+
if crds.get("capi"):
|
|
178
|
+
enabled.append("capi")
|
|
179
|
+
if crds.get("istio"):
|
|
180
|
+
enabled.append("istio")
|
|
181
|
+
|
|
182
|
+
return enabled
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_crd_status_summary(context: str = "") -> Dict:
|
|
186
|
+
"""Get a summary of CRD detection status.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
context: Kubernetes context to use
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Summary dict with detected CRDs and enabled toolsets
|
|
193
|
+
"""
|
|
194
|
+
crds = detect_crds(context)
|
|
195
|
+
enabled = get_enabled_toolsets(context)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"context": context or "current",
|
|
199
|
+
"crd_groups": crds,
|
|
200
|
+
"enabled_toolsets": enabled,
|
|
201
|
+
"total_groups_detected": sum(1 for v in crds.values() if v),
|
|
202
|
+
"total_toolsets_enabled": len(enabled),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class FeatureNotInstalledError(Exception):
|
|
207
|
+
"""Raised when required CRDs are not installed."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, toolset: str, required_crds: List[str]):
|
|
210
|
+
self.toolset = toolset
|
|
211
|
+
self.required_crds = required_crds
|
|
212
|
+
super().__init__(
|
|
213
|
+
f"{toolset} toolset requires one of these CRDs: {', '.join(required_crds)}. "
|
|
214
|
+
f"Install the required operator to use this feature."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def require_crd(crd_name: str, toolset: str, context: str = ""):
|
|
219
|
+
"""Check if a CRD exists and raise an error if not.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
crd_name: CRD name to check
|
|
223
|
+
toolset: Toolset name for error message
|
|
224
|
+
context: Kubernetes context
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
FeatureNotInstalledError: If CRD is not installed
|
|
228
|
+
"""
|
|
229
|
+
if not crd_exists(crd_name, context):
|
|
230
|
+
raise FeatureNotInstalledError(toolset, [crd_name])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def require_any_crd(crd_names: List[str], toolset: str, context: str = ""):
|
|
234
|
+
"""Check if any of the CRDs exist and raise an error if none are found.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
crd_names: List of CRD names to check
|
|
238
|
+
toolset: Toolset name for error message
|
|
239
|
+
context: Kubernetes context
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
FeatureNotInstalledError: If no CRDs are installed
|
|
243
|
+
"""
|
|
244
|
+
for crd in crd_names:
|
|
245
|
+
if crd_exists(crd, context):
|
|
246
|
+
return
|
|
247
|
+
raise FeatureNotInstalledError(toolset, crd_names)
|
kubectl_mcp_tool/k8s_config.py
CHANGED
|
@@ -509,3 +509,22 @@ def context_exists(context: str) -> bool:
|
|
|
509
509
|
"""
|
|
510
510
|
contexts = list_contexts()
|
|
511
511
|
return any(ctx["name"] == context for ctx in contexts)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _get_kubectl_context_args(context: str = "") -> list:
|
|
515
|
+
"""
|
|
516
|
+
Get kubectl command arguments for specifying a context.
|
|
517
|
+
|
|
518
|
+
This utility function returns the appropriate --context flag arguments
|
|
519
|
+
for kubectl commands when targeting a specific cluster.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
context: Context name (empty string for default context)
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
List of command arguments, e.g., ["--context", "my-cluster"]
|
|
526
|
+
or empty list if no context specified
|
|
527
|
+
"""
|
|
528
|
+
if context and context.strip():
|
|
529
|
+
return ["--context", context.strip()]
|
|
530
|
+
return []
|
kubectl_mcp_tool/mcp_server.py
CHANGED
|
@@ -23,12 +23,49 @@ import logging
|
|
|
23
23
|
import asyncio
|
|
24
24
|
import os
|
|
25
25
|
import platform
|
|
26
|
-
|
|
26
|
+
import signal
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import List, Optional, Any, Dict
|
|
27
29
|
|
|
28
30
|
# Import k8s_config early to patch kubernetes config for in-cluster support
|
|
29
31
|
# This must be done before any tools are imported
|
|
30
32
|
import kubectl_mcp_tool.k8s_config # noqa: F401
|
|
31
33
|
|
|
34
|
+
# Import safety mode for operation control
|
|
35
|
+
from kubectl_mcp_tool.safety import (
|
|
36
|
+
SafetyMode,
|
|
37
|
+
set_safety_mode,
|
|
38
|
+
get_safety_mode,
|
|
39
|
+
get_mode_info,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Import observability for metrics and tracing
|
|
43
|
+
from kubectl_mcp_tool.observability import (
|
|
44
|
+
get_stats_collector,
|
|
45
|
+
get_metrics,
|
|
46
|
+
init_tracing,
|
|
47
|
+
shutdown_tracing,
|
|
48
|
+
is_prometheus_available,
|
|
49
|
+
is_tracing_available,
|
|
50
|
+
record_tool_call_metric,
|
|
51
|
+
record_tool_duration_metric,
|
|
52
|
+
record_tool_error_metric,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Import config loader
|
|
56
|
+
from kubectl_mcp_tool.config import (
|
|
57
|
+
load_config,
|
|
58
|
+
get_config,
|
|
59
|
+
register_reload_callback,
|
|
60
|
+
setup_sighup_handler,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Import custom prompts
|
|
64
|
+
from kubectl_mcp_tool.prompts import (
|
|
65
|
+
load_prompts_from_config,
|
|
66
|
+
get_builtin_prompts,
|
|
67
|
+
)
|
|
68
|
+
|
|
32
69
|
from kubectl_mcp_tool.tools import (
|
|
33
70
|
register_helm_tools,
|
|
34
71
|
register_pod_tools,
|
|
@@ -45,6 +82,16 @@ from kubectl_mcp_tool.tools import (
|
|
|
45
82
|
is_browser_available,
|
|
46
83
|
register_ui_tools,
|
|
47
84
|
is_ui_available,
|
|
85
|
+
register_gitops_tools,
|
|
86
|
+
register_certs_tools,
|
|
87
|
+
register_policy_tools,
|
|
88
|
+
register_backup_tools,
|
|
89
|
+
register_keda_tools,
|
|
90
|
+
register_cilium_tools,
|
|
91
|
+
register_rollouts_tools,
|
|
92
|
+
register_capi_tools,
|
|
93
|
+
register_kubevirt_tools,
|
|
94
|
+
register_istio_tools,
|
|
48
95
|
)
|
|
49
96
|
from kubectl_mcp_tool.resources import register_resources
|
|
50
97
|
from kubectl_mcp_tool.prompts import register_prompts
|
|
@@ -106,12 +153,20 @@ except ImportError:
|
|
|
106
153
|
class MCPServer:
|
|
107
154
|
"""MCP server implementation."""
|
|
108
155
|
|
|
109
|
-
def __init__(
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
name: str,
|
|
159
|
+
read_only: bool = False,
|
|
160
|
+
disable_destructive: bool = False,
|
|
161
|
+
config_file: Optional[str] = None,
|
|
162
|
+
):
|
|
110
163
|
"""Initialize the MCP server.
|
|
111
164
|
|
|
112
165
|
Args:
|
|
113
166
|
name: Server name for identification
|
|
114
|
-
|
|
167
|
+
read_only: If True, block all write operations (read-only mode)
|
|
168
|
+
disable_destructive: If True, block only destructive operations
|
|
169
|
+
config_file: Optional path to TOML config file
|
|
115
170
|
|
|
116
171
|
Environment Variables:
|
|
117
172
|
MCP_AUTH_ENABLED: Enable OAuth 2.1 authentication (default: false)
|
|
@@ -121,9 +176,29 @@ class MCPServer:
|
|
|
121
176
|
MCP_AUTH_REQUIRED_SCOPES: Required scopes (default: mcp:tools)
|
|
122
177
|
"""
|
|
123
178
|
self.name = name
|
|
124
|
-
self.non_destructive = non_destructive
|
|
125
179
|
self._dependencies_checked = False
|
|
126
180
|
self._dependencies_available = None
|
|
181
|
+
self._stats = get_stats_collector()
|
|
182
|
+
|
|
183
|
+
# Persist CLI safety overrides for reloads
|
|
184
|
+
self._cli_read_only = read_only
|
|
185
|
+
self._cli_disable_destructive = disable_destructive
|
|
186
|
+
|
|
187
|
+
# Load configuration from file and environment
|
|
188
|
+
self.config = self._load_configuration(config_file)
|
|
189
|
+
|
|
190
|
+
# Apply safety mode from config or parameters
|
|
191
|
+
self._apply_safety_mode(self._cli_read_only, self._cli_disable_destructive)
|
|
192
|
+
|
|
193
|
+
# For backward compatibility, expose non_destructive
|
|
194
|
+
self.non_destructive = get_safety_mode() != SafetyMode.NORMAL
|
|
195
|
+
|
|
196
|
+
# Initialize observability (tracing, metrics)
|
|
197
|
+
self._init_observability()
|
|
198
|
+
|
|
199
|
+
# Register config reload callback and set up SIGHUP handler
|
|
200
|
+
register_reload_callback(self._on_config_reload)
|
|
201
|
+
setup_sighup_handler()
|
|
127
202
|
|
|
128
203
|
# Load authentication configuration
|
|
129
204
|
self.auth_config = get_auth_config()
|
|
@@ -140,6 +215,71 @@ class MCPServer:
|
|
|
140
215
|
self.setup_resources()
|
|
141
216
|
self.setup_prompts()
|
|
142
217
|
|
|
218
|
+
# Log startup info
|
|
219
|
+
mode_info = get_mode_info()
|
|
220
|
+
logger.info(f"MCP Server initialized: {name}")
|
|
221
|
+
logger.info(f"Safety mode: {mode_info['mode']} - {mode_info['description']}")
|
|
222
|
+
|
|
223
|
+
def _load_configuration(self, config_file: Optional[str]) -> Any:
|
|
224
|
+
"""Load configuration from TOML file and environment."""
|
|
225
|
+
try:
|
|
226
|
+
config = load_config(config_file=config_file if config_file else None)
|
|
227
|
+
logger.debug(f"Configuration loaded successfully")
|
|
228
|
+
return config
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.warning(f"Failed to load config file: {e}. Using defaults.")
|
|
231
|
+
return load_config(skip_env=False)
|
|
232
|
+
|
|
233
|
+
def _apply_safety_mode(self, read_only: bool, disable_destructive: bool) -> None:
|
|
234
|
+
"""Apply safety mode from config or CLI parameters.
|
|
235
|
+
|
|
236
|
+
CLI parameters take precedence over config file settings.
|
|
237
|
+
"""
|
|
238
|
+
# Check config first
|
|
239
|
+
config_mode = getattr(self.config.safety, 'mode', 'normal') if hasattr(self.config, 'safety') else 'normal'
|
|
240
|
+
|
|
241
|
+
# CLI parameters override config
|
|
242
|
+
if read_only:
|
|
243
|
+
set_safety_mode(SafetyMode.READ_ONLY)
|
|
244
|
+
elif disable_destructive:
|
|
245
|
+
set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
|
|
246
|
+
elif config_mode == 'read-only' or config_mode == 'read_only':
|
|
247
|
+
set_safety_mode(SafetyMode.READ_ONLY)
|
|
248
|
+
elif config_mode == 'disable-destructive' or config_mode == 'disable_destructive':
|
|
249
|
+
set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
|
|
250
|
+
else:
|
|
251
|
+
set_safety_mode(SafetyMode.NORMAL)
|
|
252
|
+
|
|
253
|
+
def _init_observability(self) -> None:
|
|
254
|
+
"""Initialize observability components (tracing, metrics)."""
|
|
255
|
+
# Check if tracing is enabled in config
|
|
256
|
+
tracing_enabled = getattr(self.config.metrics, 'tracing_enabled', False) if hasattr(self.config, 'metrics') else False
|
|
257
|
+
otlp_endpoint = getattr(self.config.metrics, 'otlp_endpoint', None) if hasattr(self.config, 'metrics') else None
|
|
258
|
+
|
|
259
|
+
if tracing_enabled or otlp_endpoint or os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT'):
|
|
260
|
+
try:
|
|
261
|
+
init_tracing()
|
|
262
|
+
logger.info("OpenTelemetry tracing initialized")
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.warning(f"Failed to initialize tracing: {e}")
|
|
265
|
+
|
|
266
|
+
if is_prometheus_available():
|
|
267
|
+
logger.debug("Prometheus metrics available")
|
|
268
|
+
|
|
269
|
+
def _on_config_reload(self, new_config: Any) -> None:
|
|
270
|
+
"""Handle configuration reload (called on SIGHUP)."""
|
|
271
|
+
logger.info("Configuration reloaded")
|
|
272
|
+
self.config = new_config
|
|
273
|
+
|
|
274
|
+
# Re-apply safety mode from new config, honoring CLI precedence
|
|
275
|
+
self._apply_safety_mode(self._cli_read_only, self._cli_disable_destructive)
|
|
276
|
+
|
|
277
|
+
# Refresh non_destructive flag
|
|
278
|
+
self.non_destructive = get_safety_mode() != SafetyMode.NORMAL
|
|
279
|
+
|
|
280
|
+
mode_info = get_mode_info()
|
|
281
|
+
logger.info(f"Safety mode after reload: {mode_info['mode']}")
|
|
282
|
+
|
|
143
283
|
def _setup_auth(self) -> Optional[Any]:
|
|
144
284
|
"""Set up authentication if enabled."""
|
|
145
285
|
if not self.auth_config.enabled:
|
|
@@ -196,13 +336,34 @@ class MCPServer:
|
|
|
196
336
|
else:
|
|
197
337
|
logger.debug("MCP-UI tools disabled (install mcp-ui-server to enable)")
|
|
198
338
|
|
|
339
|
+
# Register ecosystem tools (GitOps, Cert-Manager, Policy, Backup)
|
|
340
|
+
# These tools auto-detect installed CRDs and gracefully handle missing components
|
|
341
|
+
register_gitops_tools(self.server, self.non_destructive)
|
|
342
|
+
register_certs_tools(self.server, self.non_destructive)
|
|
343
|
+
register_policy_tools(self.server, self.non_destructive)
|
|
344
|
+
register_backup_tools(self.server, self.non_destructive)
|
|
345
|
+
logger.debug("Ecosystem tools registered (GitOps, Certs, Policy, Backup)")
|
|
346
|
+
|
|
347
|
+
# Register advanced ecosystem tools (KEDA, Cilium, Rollouts, CAPI, KubeVirt, Istio)
|
|
348
|
+
register_keda_tools(self.server, self.non_destructive)
|
|
349
|
+
register_cilium_tools(self.server, self.non_destructive)
|
|
350
|
+
register_rollouts_tools(self.server, self.non_destructive)
|
|
351
|
+
register_capi_tools(self.server, self.non_destructive)
|
|
352
|
+
register_kubevirt_tools(self.server, self.non_destructive)
|
|
353
|
+
register_istio_tools(self.server, self.non_destructive)
|
|
354
|
+
logger.debug("Advanced ecosystem tools registered (KEDA, Cilium, Rollouts, CAPI, KubeVirt, Istio)")
|
|
355
|
+
|
|
199
356
|
def setup_resources(self):
|
|
200
357
|
"""Set up MCP resources for Kubernetes data exposure."""
|
|
201
358
|
register_resources(self.server)
|
|
202
359
|
|
|
203
360
|
def setup_prompts(self):
|
|
204
|
-
"""Set up MCP prompts."""
|
|
205
|
-
|
|
361
|
+
"""Set up MCP prompts from built-in and custom config."""
|
|
362
|
+
# Get custom prompts path from config if specified
|
|
363
|
+
prompts_config_path = None
|
|
364
|
+
if hasattr(self.config, 'prompts') and hasattr(self.config.prompts, 'file'):
|
|
365
|
+
prompts_config_path = self.config.prompts.file
|
|
366
|
+
register_prompts(self.server, config_path=prompts_config_path)
|
|
206
367
|
|
|
207
368
|
def _check_dependencies(self) -> bool:
|
|
208
369
|
"""Check if required dependencies are available."""
|
|
@@ -331,17 +492,51 @@ class MCPServer:
|
|
|
331
492
|
try:
|
|
332
493
|
# FastMCP 3 uses create_sse_app() to create a Starlette ASGI app
|
|
333
494
|
from fastmcp.server.http import create_sse_app
|
|
495
|
+
from starlette.applications import Starlette
|
|
496
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
497
|
+
from starlette.routing import Route, Mount
|
|
498
|
+
|
|
499
|
+
# Create observability endpoints
|
|
500
|
+
async def health_check(request):
|
|
501
|
+
return JSONResponse({"status": "healthy", "server": self.name})
|
|
502
|
+
|
|
503
|
+
async def stats_endpoint(request):
|
|
504
|
+
stats = self._stats.get_stats()
|
|
505
|
+
return JSONResponse(stats)
|
|
506
|
+
|
|
507
|
+
async def metrics_endpoint(request):
|
|
508
|
+
if is_prometheus_available():
|
|
509
|
+
metrics_text = get_metrics()
|
|
510
|
+
return PlainTextResponse(metrics_text, media_type="text/plain; version=0.0.4; charset=utf-8")
|
|
511
|
+
else:
|
|
512
|
+
return PlainTextResponse("# Prometheus metrics not available\n", media_type="text/plain")
|
|
513
|
+
|
|
514
|
+
async def safety_mode_endpoint(request):
|
|
515
|
+
mode_info = get_mode_info()
|
|
516
|
+
return JSONResponse(mode_info)
|
|
334
517
|
|
|
335
518
|
# Create the SSE Starlette application
|
|
336
519
|
# message_path: POST endpoint for client messages
|
|
337
520
|
# sse_path: GET endpoint for SSE event stream
|
|
338
|
-
|
|
521
|
+
sse_app = create_sse_app(
|
|
339
522
|
self.server,
|
|
340
523
|
message_path="/messages/",
|
|
341
524
|
sse_path="/sse"
|
|
342
525
|
)
|
|
343
526
|
|
|
527
|
+
# Create combined app with SSE and observability endpoints
|
|
528
|
+
app = Starlette(
|
|
529
|
+
routes=[
|
|
530
|
+
Route("/health", health_check, methods=["GET"]),
|
|
531
|
+
Route("/stats", stats_endpoint, methods=["GET"]),
|
|
532
|
+
Route("/metrics", metrics_endpoint, methods=["GET"]),
|
|
533
|
+
Route("/safety", safety_mode_endpoint, methods=["GET"]),
|
|
534
|
+
Mount("/", app=sse_app), # Mount SSE app at root
|
|
535
|
+
]
|
|
536
|
+
)
|
|
537
|
+
|
|
344
538
|
logger.info(f"SSE endpoints: GET /sse (events), POST /messages/ (messages)")
|
|
539
|
+
logger.info(f"Observability endpoints: GET /health, /stats, /metrics, /safety")
|
|
345
540
|
|
|
346
541
|
# Run with uvicorn
|
|
347
542
|
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
|
@@ -471,11 +666,33 @@ class MCPServer:
|
|
|
471
666
|
"""Health check endpoint."""
|
|
472
667
|
return JSONResponse({"status": "healthy", "server": self.name})
|
|
473
668
|
|
|
669
|
+
async def stats_endpoint(request):
|
|
670
|
+
"""Return runtime statistics."""
|
|
671
|
+
stats = self._stats.get_stats()
|
|
672
|
+
return JSONResponse(stats)
|
|
673
|
+
|
|
674
|
+
async def metrics_endpoint(request):
|
|
675
|
+
"""Return Prometheus-format metrics."""
|
|
676
|
+
from starlette.responses import PlainTextResponse
|
|
677
|
+
if is_prometheus_available():
|
|
678
|
+
metrics_text = get_metrics()
|
|
679
|
+
return PlainTextResponse(metrics_text, media_type="text/plain; version=0.0.4; charset=utf-8")
|
|
680
|
+
else:
|
|
681
|
+
return PlainTextResponse("# Prometheus metrics not available\n", media_type="text/plain")
|
|
682
|
+
|
|
683
|
+
async def safety_mode_endpoint(request):
|
|
684
|
+
"""Return current safety mode information."""
|
|
685
|
+
mode_info = get_mode_info()
|
|
686
|
+
return JSONResponse(mode_info)
|
|
687
|
+
|
|
474
688
|
app = Starlette(
|
|
475
689
|
routes=[
|
|
476
690
|
Route("/", handle_mcp_request, methods=["POST"]),
|
|
477
691
|
Route("/mcp", handle_mcp_request, methods=["POST"]),
|
|
478
692
|
Route("/health", health_check, methods=["GET"]),
|
|
693
|
+
Route("/stats", stats_endpoint, methods=["GET"]),
|
|
694
|
+
Route("/metrics", metrics_endpoint, methods=["GET"]),
|
|
695
|
+
Route("/safety", safety_mode_endpoint, methods=["GET"]),
|
|
479
696
|
]
|
|
480
697
|
)
|
|
481
698
|
|
|
@@ -508,10 +725,31 @@ if __name__ == "__main__":
|
|
|
508
725
|
default="0.0.0.0",
|
|
509
726
|
help="Host to bind to for SSE/HTTP transport. Default: 0.0.0.0.",
|
|
510
727
|
)
|
|
728
|
+
parser.add_argument(
|
|
729
|
+
"--config",
|
|
730
|
+
type=str,
|
|
731
|
+
default=None,
|
|
732
|
+
help="Path to TOML configuration file.",
|
|
733
|
+
)
|
|
734
|
+
parser.add_argument(
|
|
735
|
+
"--read-only",
|
|
736
|
+
action="store_true",
|
|
737
|
+
help="Enable read-only mode (block all write operations).",
|
|
738
|
+
)
|
|
739
|
+
parser.add_argument(
|
|
740
|
+
"--disable-destructive",
|
|
741
|
+
action="store_true",
|
|
742
|
+
help="Disable destructive operations (allow create/update, block delete).",
|
|
743
|
+
)
|
|
511
744
|
args = parser.parse_args()
|
|
512
745
|
|
|
513
746
|
server_name = "kubectl_mcp_server"
|
|
514
|
-
mcp_server = MCPServer(
|
|
747
|
+
mcp_server = MCPServer(
|
|
748
|
+
name=server_name,
|
|
749
|
+
read_only=args.read_only,
|
|
750
|
+
disable_destructive=args.disable_destructive,
|
|
751
|
+
config_file=args.config,
|
|
752
|
+
)
|
|
515
753
|
|
|
516
754
|
# Handle signals gracefully with immediate exit
|
|
517
755
|
def signal_handler(sig, frame):
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observability module for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- StatsCollector: Runtime statistics and metrics collection
|
|
6
|
+
- Prometheus metrics: Standard Prometheus format metrics
|
|
7
|
+
- OpenTelemetry tracing: Distributed tracing with OTLP export
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# Stats collection
|
|
11
|
+
from kubectl_mcp_tool.observability import get_stats_collector
|
|
12
|
+
stats = get_stats_collector()
|
|
13
|
+
stats.record_tool_call("get_pods", success=True, duration=0.5)
|
|
14
|
+
|
|
15
|
+
# Prometheus metrics
|
|
16
|
+
from kubectl_mcp_tool.observability import get_metrics
|
|
17
|
+
metrics_text = get_metrics()
|
|
18
|
+
|
|
19
|
+
# Tracing
|
|
20
|
+
from kubectl_mcp_tool.observability import init_tracing, traced_tool_call
|
|
21
|
+
init_tracing()
|
|
22
|
+
with traced_tool_call("get_pods") as span:
|
|
23
|
+
# execute tool
|
|
24
|
+
pass
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .stats import StatsCollector, get_stats_collector
|
|
28
|
+
from .metrics import (
|
|
29
|
+
get_metrics,
|
|
30
|
+
record_tool_call_metric,
|
|
31
|
+
record_tool_error_metric,
|
|
32
|
+
record_tool_duration_metric,
|
|
33
|
+
is_prometheus_available,
|
|
34
|
+
)
|
|
35
|
+
from .tracing import (
|
|
36
|
+
init_tracing,
|
|
37
|
+
traced_tool_call,
|
|
38
|
+
get_tracer,
|
|
39
|
+
is_tracing_available,
|
|
40
|
+
shutdown_tracing,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# Stats
|
|
45
|
+
"StatsCollector",
|
|
46
|
+
"get_stats_collector",
|
|
47
|
+
# Metrics
|
|
48
|
+
"get_metrics",
|
|
49
|
+
"record_tool_call_metric",
|
|
50
|
+
"record_tool_error_metric",
|
|
51
|
+
"record_tool_duration_metric",
|
|
52
|
+
"is_prometheus_available",
|
|
53
|
+
# Tracing
|
|
54
|
+
"init_tracing",
|
|
55
|
+
"traced_tool_call",
|
|
56
|
+
"get_tracer",
|
|
57
|
+
"is_tracing_available",
|
|
58
|
+
"shutdown_tracing",
|
|
59
|
+
]
|