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,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom prompt loading and rendering system for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
Supports user-defined workflow prompts via TOML configuration file with
|
|
5
|
+
Mustache-style template syntax ({{variable}}) and conditional sections.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import logging
|
|
10
|
+
from typing import List, Dict, Any, Optional
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("mcp-server")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PromptArgument:
|
|
18
|
+
"""Definition of a prompt argument."""
|
|
19
|
+
name: str
|
|
20
|
+
description: str = ""
|
|
21
|
+
required: bool = False
|
|
22
|
+
default: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PromptMessage:
|
|
27
|
+
"""A single message in a prompt conversation."""
|
|
28
|
+
role: str # "user" or "assistant"
|
|
29
|
+
content: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CustomPrompt:
|
|
34
|
+
"""A custom prompt definition."""
|
|
35
|
+
name: str
|
|
36
|
+
description: str
|
|
37
|
+
title: str = ""
|
|
38
|
+
arguments: List[PromptArgument] = field(default_factory=list)
|
|
39
|
+
messages: List[PromptMessage] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_prompt(prompt: CustomPrompt, args: Dict[str, str]) -> List[PromptMessage]:
|
|
43
|
+
"""
|
|
44
|
+
Render prompt messages with argument substitution using {{arg_name}} syntax.
|
|
45
|
+
|
|
46
|
+
Supports:
|
|
47
|
+
- Simple substitution: {{variable}} -> value
|
|
48
|
+
- Conditional sections: {{#variable}}content{{/variable}} (shown if variable is truthy)
|
|
49
|
+
- Inverse sections: {{^variable}}content{{/variable}} (shown if variable is falsy)
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
prompt: The CustomPrompt to render
|
|
53
|
+
args: Dictionary of argument names to values
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of PromptMessage with rendered content
|
|
57
|
+
"""
|
|
58
|
+
rendered = []
|
|
59
|
+
for msg in prompt.messages:
|
|
60
|
+
content = msg.content
|
|
61
|
+
|
|
62
|
+
# Process conditional sections ({{#var}}...{{/var}})
|
|
63
|
+
# These are shown only if the variable exists and is truthy
|
|
64
|
+
def process_conditional(match):
|
|
65
|
+
var_name = match.group(1)
|
|
66
|
+
section_content = match.group(2)
|
|
67
|
+
var_value = args.get(var_name, "")
|
|
68
|
+
# Only render section if variable exists and is not empty/false
|
|
69
|
+
if var_value and var_value.lower() not in ("false", "0", "no"):
|
|
70
|
+
# Recursively process the section content for variable substitution
|
|
71
|
+
processed = section_content
|
|
72
|
+
for key, value in args.items():
|
|
73
|
+
processed = processed.replace(f"{{{{{key}}}}}", str(value))
|
|
74
|
+
return processed
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
content = re.sub(
|
|
78
|
+
r'\{\{#(\w+)\}\}(.*?)\{\{/\1\}\}',
|
|
79
|
+
process_conditional,
|
|
80
|
+
content,
|
|
81
|
+
flags=re.DOTALL
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Process inverse sections ({{^var}}...{{/var}})
|
|
85
|
+
# These are shown only if the variable is missing or falsy
|
|
86
|
+
def process_inverse(match):
|
|
87
|
+
var_name = match.group(1)
|
|
88
|
+
section_content = match.group(2)
|
|
89
|
+
var_value = args.get(var_name, "")
|
|
90
|
+
# Only render section if variable is missing or falsy
|
|
91
|
+
if not var_value or var_value.lower() in ("false", "0", "no"):
|
|
92
|
+
processed = section_content
|
|
93
|
+
for key, value in args.items():
|
|
94
|
+
processed = processed.replace(f"{{{{{key}}}}}", str(value))
|
|
95
|
+
return processed
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
content = re.sub(
|
|
99
|
+
r'\{\{\^(\w+)\}\}(.*?)\{\{/\1\}\}',
|
|
100
|
+
process_inverse,
|
|
101
|
+
content,
|
|
102
|
+
flags=re.DOTALL
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Simple variable substitution
|
|
106
|
+
for key, value in args.items():
|
|
107
|
+
content = content.replace(f"{{{{{key}}}}}", str(value))
|
|
108
|
+
|
|
109
|
+
# Remove unsubstituted optional placeholders (simple variables only)
|
|
110
|
+
content = re.sub(r'\{\{[^#^/][^}]*\}\}', '', content)
|
|
111
|
+
|
|
112
|
+
# Clean up any remaining empty lines from removed sections
|
|
113
|
+
content = re.sub(r'\n\s*\n\s*\n', '\n\n', content)
|
|
114
|
+
|
|
115
|
+
rendered.append(PromptMessage(role=msg.role, content=content.strip()))
|
|
116
|
+
|
|
117
|
+
return rendered
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_prompts_from_config(config: dict) -> List[CustomPrompt]:
|
|
121
|
+
"""
|
|
122
|
+
Load prompts from config dict (from TOML).
|
|
123
|
+
|
|
124
|
+
Expected config structure:
|
|
125
|
+
{
|
|
126
|
+
"prompts": [
|
|
127
|
+
{
|
|
128
|
+
"name": "debug-pod",
|
|
129
|
+
"title": "Debug Pod Issues",
|
|
130
|
+
"description": "Diagnose pod problems",
|
|
131
|
+
"arguments": [
|
|
132
|
+
{"name": "pod_name", "required": True, "description": "Pod to debug"},
|
|
133
|
+
{"name": "namespace", "required": False, "default": "default"}
|
|
134
|
+
],
|
|
135
|
+
"messages": [
|
|
136
|
+
{"role": "user", "content": "Debug pod {{pod_name}} in namespace {{namespace}}"}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
config: Dictionary loaded from TOML configuration
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
List of CustomPrompt objects
|
|
147
|
+
"""
|
|
148
|
+
prompts = []
|
|
149
|
+
|
|
150
|
+
prompt_configs = config.get("prompts", [])
|
|
151
|
+
if not prompt_configs:
|
|
152
|
+
return prompts
|
|
153
|
+
|
|
154
|
+
for prompt_config in prompt_configs:
|
|
155
|
+
try:
|
|
156
|
+
# Parse arguments
|
|
157
|
+
arguments = []
|
|
158
|
+
for arg_config in prompt_config.get("arguments", []):
|
|
159
|
+
arg = PromptArgument(
|
|
160
|
+
name=arg_config.get("name", ""),
|
|
161
|
+
description=arg_config.get("description", ""),
|
|
162
|
+
required=arg_config.get("required", False),
|
|
163
|
+
default=arg_config.get("default", "")
|
|
164
|
+
)
|
|
165
|
+
if arg.name: # Only add valid arguments
|
|
166
|
+
arguments.append(arg)
|
|
167
|
+
|
|
168
|
+
# Parse messages
|
|
169
|
+
messages = []
|
|
170
|
+
for msg_config in prompt_config.get("messages", []):
|
|
171
|
+
msg = PromptMessage(
|
|
172
|
+
role=msg_config.get("role", "user"),
|
|
173
|
+
content=msg_config.get("content", "")
|
|
174
|
+
)
|
|
175
|
+
if msg.content: # Only add non-empty messages
|
|
176
|
+
messages.append(msg)
|
|
177
|
+
|
|
178
|
+
# Create prompt
|
|
179
|
+
prompt = CustomPrompt(
|
|
180
|
+
name=prompt_config.get("name", ""),
|
|
181
|
+
title=prompt_config.get("title", prompt_config.get("name", "")),
|
|
182
|
+
description=prompt_config.get("description", ""),
|
|
183
|
+
arguments=arguments,
|
|
184
|
+
messages=messages
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if prompt.name: # Only add valid prompts
|
|
188
|
+
prompts.append(prompt)
|
|
189
|
+
logger.debug(f"Loaded custom prompt: {prompt.name}")
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.warning(f"Failed to parse prompt config: {e}")
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
return prompts
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def load_prompts_from_toml_file(file_path: str) -> List[CustomPrompt]:
|
|
199
|
+
"""
|
|
200
|
+
Load prompts from a TOML file.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
file_path: Path to the TOML configuration file
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
List of CustomPrompt objects
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
import tomllib
|
|
210
|
+
except ImportError:
|
|
211
|
+
try:
|
|
212
|
+
import tomli as tomllib
|
|
213
|
+
except ImportError:
|
|
214
|
+
logger.warning("TOML parsing not available. Install tomli for Python < 3.11")
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
with open(file_path, "rb") as f:
|
|
219
|
+
config = tomllib.load(f)
|
|
220
|
+
return load_prompts_from_config(config)
|
|
221
|
+
except FileNotFoundError:
|
|
222
|
+
logger.debug(f"Custom prompts file not found: {file_path}")
|
|
223
|
+
return []
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.warning(f"Failed to load prompts from {file_path}: {e}")
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def validate_prompt_args(prompt: CustomPrompt, args: Dict[str, str]) -> List[str]:
|
|
230
|
+
"""
|
|
231
|
+
Validate that all required arguments are provided.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
prompt: The CustomPrompt to validate against
|
|
235
|
+
args: Dictionary of argument names to values
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
List of error messages (empty if valid)
|
|
239
|
+
"""
|
|
240
|
+
errors = []
|
|
241
|
+
|
|
242
|
+
for arg in prompt.arguments:
|
|
243
|
+
if arg.required and arg.name not in args:
|
|
244
|
+
errors.append(f"Missing required argument: {arg.name}")
|
|
245
|
+
elif arg.required and not args.get(arg.name):
|
|
246
|
+
errors.append(f"Required argument cannot be empty: {arg.name}")
|
|
247
|
+
|
|
248
|
+
return errors
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def apply_defaults(prompt: CustomPrompt, args: Dict[str, str]) -> Dict[str, str]:
|
|
252
|
+
"""
|
|
253
|
+
Apply default values for missing optional arguments.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
prompt: The CustomPrompt with argument definitions
|
|
257
|
+
args: Dictionary of argument names to values
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
New dictionary with defaults applied
|
|
261
|
+
"""
|
|
262
|
+
result = dict(args)
|
|
263
|
+
|
|
264
|
+
for arg in prompt.arguments:
|
|
265
|
+
if arg.name not in result and arg.default:
|
|
266
|
+
result[arg.name] = arg.default
|
|
267
|
+
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_prompt_schema(prompt: CustomPrompt) -> Dict[str, Any]:
|
|
272
|
+
"""
|
|
273
|
+
Generate JSON Schema for prompt arguments.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
prompt: The CustomPrompt to generate schema for
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
JSON Schema dictionary
|
|
280
|
+
"""
|
|
281
|
+
properties = {}
|
|
282
|
+
required = []
|
|
283
|
+
|
|
284
|
+
for arg in prompt.arguments:
|
|
285
|
+
properties[arg.name] = {
|
|
286
|
+
"type": "string",
|
|
287
|
+
"description": arg.description or f"Argument: {arg.name}"
|
|
288
|
+
}
|
|
289
|
+
if arg.default:
|
|
290
|
+
properties[arg.name]["default"] = arg.default
|
|
291
|
+
if arg.required:
|
|
292
|
+
required.append(arg.name)
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"type": "object",
|
|
296
|
+
"properties": properties,
|
|
297
|
+
"required": required
|
|
298
|
+
}
|
|
@@ -1,14 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP prompts registration for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
This module handles registration of both built-in and custom prompts.
|
|
5
|
+
Custom prompts can be loaded from a TOML configuration file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
1
8
|
import logging
|
|
2
|
-
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
from .custom import (
|
|
13
|
+
CustomPrompt,
|
|
14
|
+
PromptMessage,
|
|
15
|
+
render_prompt,
|
|
16
|
+
load_prompts_from_toml_file,
|
|
17
|
+
validate_prompt_args,
|
|
18
|
+
apply_defaults,
|
|
19
|
+
)
|
|
20
|
+
from .builtin import get_builtin_prompts
|
|
3
21
|
|
|
4
22
|
logger = logging.getLogger("mcp-server")
|
|
5
23
|
|
|
6
24
|
|
|
7
|
-
|
|
8
|
-
|
|
25
|
+
# Default paths for custom prompts configuration
|
|
26
|
+
DEFAULT_CONFIG_PATHS = [
|
|
27
|
+
os.path.expanduser("~/.kubectl-mcp/prompts.toml"),
|
|
28
|
+
os.path.expanduser("~/.config/kubectl-mcp/prompts.toml"),
|
|
29
|
+
"./kubectl-mcp-prompts.toml",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_custom_prompts_path() -> Optional[str]:
|
|
34
|
+
"""
|
|
35
|
+
Get the path to custom prompts configuration file.
|
|
36
|
+
|
|
37
|
+
Checks (in order):
|
|
38
|
+
1. MCP_PROMPTS_FILE environment variable
|
|
39
|
+
2. Default config paths
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Path to config file if found, None otherwise
|
|
43
|
+
"""
|
|
44
|
+
# Check environment variable first
|
|
45
|
+
env_path = os.environ.get("MCP_PROMPTS_FILE")
|
|
46
|
+
if env_path and os.path.isfile(env_path):
|
|
47
|
+
return env_path
|
|
48
|
+
|
|
49
|
+
# Check default paths
|
|
50
|
+
for path in DEFAULT_CONFIG_PATHS:
|
|
51
|
+
if os.path.isfile(path):
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _merge_prompts(builtin: list, custom: list) -> Dict[str, CustomPrompt]:
|
|
58
|
+
"""
|
|
59
|
+
Merge built-in and custom prompts.
|
|
60
|
+
|
|
61
|
+
Custom prompts override built-in prompts with the same name.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
builtin: List of built-in CustomPrompt objects
|
|
65
|
+
custom: List of custom CustomPrompt objects
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dictionary of prompt name -> CustomPrompt
|
|
69
|
+
"""
|
|
70
|
+
prompts = {}
|
|
71
|
+
|
|
72
|
+
# Add built-in prompts first
|
|
73
|
+
for prompt in builtin:
|
|
74
|
+
prompts[prompt.name] = prompt
|
|
75
|
+
|
|
76
|
+
# Custom prompts override built-in
|
|
77
|
+
for prompt in custom:
|
|
78
|
+
if prompt.name in prompts:
|
|
79
|
+
logger.info(f"Custom prompt '{prompt.name}' overrides built-in prompt")
|
|
80
|
+
prompts[prompt.name] = prompt
|
|
81
|
+
|
|
82
|
+
return prompts
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def register_prompts(server, config_path: Optional[str] = None):
|
|
86
|
+
"""
|
|
87
|
+
Register all MCP prompts for Kubernetes workflows.
|
|
88
|
+
|
|
89
|
+
Registers:
|
|
90
|
+
1. Built-in prompts from builtin.py
|
|
91
|
+
2. Custom prompts from configuration file (if found)
|
|
92
|
+
3. Original inline prompts for backward compatibility
|
|
93
|
+
|
|
94
|
+
Custom prompts can override built-in prompts by using the same name.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
server: FastMCP server instance
|
|
98
|
+
config_path: Optional path to custom prompts TOML file
|
|
99
|
+
"""
|
|
100
|
+
# Load built-in prompts
|
|
101
|
+
builtin_prompts = get_builtin_prompts()
|
|
102
|
+
logger.debug(f"Loaded {len(builtin_prompts)} built-in prompts")
|
|
103
|
+
|
|
104
|
+
# Load custom prompts
|
|
105
|
+
prompts_file = config_path or _get_custom_prompts_path()
|
|
106
|
+
custom_prompts = []
|
|
107
|
+
if prompts_file:
|
|
108
|
+
custom_prompts = load_prompts_from_toml_file(prompts_file)
|
|
109
|
+
logger.info(f"Loaded {len(custom_prompts)} custom prompts from {prompts_file}")
|
|
110
|
+
|
|
111
|
+
# Merge prompts (custom overrides built-in)
|
|
112
|
+
all_prompts = _merge_prompts(builtin_prompts, custom_prompts)
|
|
113
|
+
|
|
114
|
+
# Register each configurable prompt
|
|
115
|
+
for prompt in all_prompts.values():
|
|
116
|
+
_register_custom_prompt(server, prompt)
|
|
117
|
+
|
|
118
|
+
logger.debug(f"Registered {len(all_prompts)} configurable prompts")
|
|
119
|
+
|
|
120
|
+
# Register original inline prompts for backward compatibility
|
|
121
|
+
_register_inline_prompts(server)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _register_custom_prompt(server, prompt: CustomPrompt):
|
|
125
|
+
"""
|
|
126
|
+
Register a single CustomPrompt with the server.
|
|
9
127
|
|
|
10
128
|
Args:
|
|
11
129
|
server: FastMCP server instance
|
|
130
|
+
prompt: CustomPrompt to register
|
|
131
|
+
"""
|
|
132
|
+
# Build the argument schema for FastMCP
|
|
133
|
+
def create_prompt_handler(p: CustomPrompt):
|
|
134
|
+
"""Create a closure that captures the prompt."""
|
|
135
|
+
def handler(**kwargs) -> str:
|
|
136
|
+
# Apply defaults for missing optional arguments
|
|
137
|
+
args = apply_defaults(p, kwargs)
|
|
138
|
+
|
|
139
|
+
# Validate required arguments
|
|
140
|
+
errors = validate_prompt_args(p, args)
|
|
141
|
+
if errors:
|
|
142
|
+
return f"Error: {'; '.join(errors)}"
|
|
143
|
+
|
|
144
|
+
# Render the prompt messages
|
|
145
|
+
rendered = render_prompt(p, args)
|
|
146
|
+
|
|
147
|
+
# Return the content (for now, just the first message)
|
|
148
|
+
# MCP prompts typically return a single string
|
|
149
|
+
if rendered:
|
|
150
|
+
return rendered[0].content
|
|
151
|
+
return f"Prompt '{p.name}' has no messages defined."
|
|
152
|
+
|
|
153
|
+
return handler
|
|
154
|
+
|
|
155
|
+
# Create the handler
|
|
156
|
+
handler = create_prompt_handler(prompt)
|
|
157
|
+
|
|
158
|
+
# Set function metadata for FastMCP registration
|
|
159
|
+
handler.__name__ = prompt.name.replace("-", "_")
|
|
160
|
+
handler.__doc__ = prompt.description or prompt.title
|
|
161
|
+
|
|
162
|
+
# Build parameter annotations from arguments
|
|
163
|
+
params = {}
|
|
164
|
+
for arg in prompt.arguments:
|
|
165
|
+
# All prompt arguments are strings with Optional if not required
|
|
166
|
+
if arg.required:
|
|
167
|
+
params[arg.name] = str
|
|
168
|
+
else:
|
|
169
|
+
params[arg.name] = Optional[str]
|
|
170
|
+
|
|
171
|
+
handler.__annotations__ = params
|
|
172
|
+
handler.__annotations__["return"] = str
|
|
173
|
+
|
|
174
|
+
# Register with server
|
|
175
|
+
try:
|
|
176
|
+
server.prompt()(handler)
|
|
177
|
+
logger.debug(f"Registered configurable prompt: {prompt.name}")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.warning(f"Failed to register prompt '{prompt.name}': {e}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _register_inline_prompts(server):
|
|
183
|
+
"""
|
|
184
|
+
Register original inline prompts for backward compatibility.
|
|
185
|
+
|
|
186
|
+
These prompts are kept for users who may be using them directly.
|
|
187
|
+
They can be overridden by custom prompts with the same name.
|
|
12
188
|
"""
|
|
13
189
|
|
|
14
190
|
@server.prompt()
|
|
@@ -605,7 +781,7 @@ Target Replicas: {target_replicas}
|
|
|
605
781
|
|
|
606
782
|
### Step 2: Capacity Planning
|
|
607
783
|
Calculate required resources:
|
|
608
|
-
- Current pod resources
|
|
784
|
+
- Current pod resources x {target_replicas} = Total needed
|
|
609
785
|
- Check node capacity: `kubectl_top("nodes")`
|
|
610
786
|
- Verify cluster can accommodate new pods
|
|
611
787
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Safety mode implementation for kubectl-mcp-server.
|
|
3
|
+
|
|
4
|
+
Provides read-only and disable-destructive modes to prevent accidental cluster mutations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Any, Callable, Dict, Set
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("mcp-server")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SafetyMode(Enum):
|
|
16
|
+
"""Safety mode levels for the MCP server."""
|
|
17
|
+
NORMAL = "normal"
|
|
18
|
+
READ_ONLY = "read_only"
|
|
19
|
+
DISABLE_DESTRUCTIVE = "disable_destructive"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Global safety mode state
|
|
23
|
+
_current_mode: SafetyMode = SafetyMode.NORMAL
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Operations that modify cluster state (blocked in READ_ONLY mode)
|
|
27
|
+
WRITE_OPERATIONS: Set[str] = {
|
|
28
|
+
# Pod operations
|
|
29
|
+
"run_pod", "delete_pod",
|
|
30
|
+
# Deployment operations
|
|
31
|
+
"scale_deployment", "restart_deployment", "delete_deployment",
|
|
32
|
+
"rollback_deployment", "create_deployment", "update_deployment",
|
|
33
|
+
# StatefulSet operations
|
|
34
|
+
"scale_statefulset", "restart_statefulset", "delete_statefulset",
|
|
35
|
+
# DaemonSet operations
|
|
36
|
+
"restart_daemonset", "delete_daemonset",
|
|
37
|
+
# Service operations
|
|
38
|
+
"create_service", "delete_service", "update_service",
|
|
39
|
+
# ConfigMap/Secret operations
|
|
40
|
+
"create_configmap", "delete_configmap", "update_configmap",
|
|
41
|
+
"create_secret", "delete_secret", "update_secret",
|
|
42
|
+
# Namespace operations
|
|
43
|
+
"create_namespace", "delete_namespace",
|
|
44
|
+
# Helm operations
|
|
45
|
+
"install_helm_chart", "upgrade_helm_chart", "uninstall_helm_chart",
|
|
46
|
+
"rollback_helm_release",
|
|
47
|
+
# kubectl operations
|
|
48
|
+
"apply_manifest", "delete_resource", "patch_resource",
|
|
49
|
+
"create_resource", "replace_resource",
|
|
50
|
+
# Context operations
|
|
51
|
+
"switch_context", "set_namespace_for_context",
|
|
52
|
+
# Rollout operations
|
|
53
|
+
"rollout_promote_tool", "rollout_abort_tool", "rollout_retry_tool",
|
|
54
|
+
"rollout_restart_tool",
|
|
55
|
+
# KubeVirt operations
|
|
56
|
+
"kubevirt_vm_start_tool", "kubevirt_vm_stop_tool", "kubevirt_vm_restart_tool",
|
|
57
|
+
"kubevirt_vm_pause_tool", "kubevirt_vm_unpause_tool", "kubevirt_vm_migrate_tool",
|
|
58
|
+
# CAPI operations
|
|
59
|
+
"capi_machinedeployment_scale_tool",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Operations that are destructive (blocked in DISABLE_DESTRUCTIVE mode)
|
|
63
|
+
DESTRUCTIVE_OPERATIONS: Set[str] = {
|
|
64
|
+
# Delete operations
|
|
65
|
+
"delete_pod", "delete_deployment", "delete_statefulset", "delete_daemonset",
|
|
66
|
+
"delete_service", "delete_configmap", "delete_secret", "delete_namespace",
|
|
67
|
+
"delete_resource",
|
|
68
|
+
# Helm uninstall
|
|
69
|
+
"uninstall_helm_chart",
|
|
70
|
+
# Rollout abort
|
|
71
|
+
"rollout_abort_tool",
|
|
72
|
+
# VM stop
|
|
73
|
+
"kubevirt_vm_stop_tool",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_safety_mode() -> SafetyMode:
|
|
78
|
+
"""Get the current safety mode."""
|
|
79
|
+
return _current_mode
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_safety_mode(mode: SafetyMode) -> None:
|
|
83
|
+
"""Set the safety mode globally."""
|
|
84
|
+
global _current_mode
|
|
85
|
+
_current_mode = mode
|
|
86
|
+
logger.info(f"Safety mode set to: {mode.value}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_operation_allowed(operation_name: str) -> tuple[bool, str]:
|
|
90
|
+
"""
|
|
91
|
+
Check if an operation is allowed under the current safety mode.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Tuple of (allowed: bool, reason: str)
|
|
95
|
+
"""
|
|
96
|
+
mode = get_safety_mode()
|
|
97
|
+
|
|
98
|
+
if mode == SafetyMode.NORMAL:
|
|
99
|
+
return True, ""
|
|
100
|
+
|
|
101
|
+
if mode == SafetyMode.READ_ONLY:
|
|
102
|
+
if operation_name in WRITE_OPERATIONS or operation_name in DESTRUCTIVE_OPERATIONS:
|
|
103
|
+
return False, f"Operation '{operation_name}' blocked: read-only mode is enabled"
|
|
104
|
+
|
|
105
|
+
if mode == SafetyMode.DISABLE_DESTRUCTIVE:
|
|
106
|
+
if operation_name in DESTRUCTIVE_OPERATIONS:
|
|
107
|
+
return False, f"Operation '{operation_name}' blocked: destructive operations are disabled"
|
|
108
|
+
|
|
109
|
+
return True, ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_safety_mode(func: Callable) -> Callable:
|
|
113
|
+
"""
|
|
114
|
+
Decorator to check safety mode before executing a tool function.
|
|
115
|
+
|
|
116
|
+
Usage:
|
|
117
|
+
@check_safety_mode
|
|
118
|
+
def delete_pod(...):
|
|
119
|
+
...
|
|
120
|
+
"""
|
|
121
|
+
@wraps(func)
|
|
122
|
+
def wrapper(*args, **kwargs) -> Dict[str, Any]:
|
|
123
|
+
operation_name = func.__name__
|
|
124
|
+
allowed, reason = is_operation_allowed(operation_name)
|
|
125
|
+
|
|
126
|
+
if not allowed:
|
|
127
|
+
logger.warning(f"Blocked operation: {operation_name} (mode: {get_safety_mode().value})")
|
|
128
|
+
return {
|
|
129
|
+
"success": False,
|
|
130
|
+
"error": reason,
|
|
131
|
+
"blocked_by": get_safety_mode().value,
|
|
132
|
+
"operation": operation_name
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return func(*args, **kwargs)
|
|
136
|
+
|
|
137
|
+
return wrapper
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_mode_info() -> Dict[str, Any]:
|
|
141
|
+
"""Get information about the current safety mode."""
|
|
142
|
+
mode = get_safety_mode()
|
|
143
|
+
return {
|
|
144
|
+
"mode": mode.value,
|
|
145
|
+
"description": {
|
|
146
|
+
SafetyMode.NORMAL: "All operations allowed",
|
|
147
|
+
SafetyMode.READ_ONLY: "Only read operations allowed (no create/update/delete)",
|
|
148
|
+
SafetyMode.DISABLE_DESTRUCTIVE: "Create/update allowed, delete operations blocked",
|
|
149
|
+
}[mode],
|
|
150
|
+
"blocked_operations": {
|
|
151
|
+
SafetyMode.NORMAL: [],
|
|
152
|
+
SafetyMode.READ_ONLY: sorted(WRITE_OPERATIONS | DESTRUCTIVE_OPERATIONS),
|
|
153
|
+
SafetyMode.DISABLE_DESTRUCTIVE: sorted(DESTRUCTIVE_OPERATIONS),
|
|
154
|
+
}[mode]
|
|
155
|
+
}
|
|
@@ -11,6 +11,16 @@ from .diagnostics import register_diagnostics_tools
|
|
|
11
11
|
from .cost import register_cost_tools
|
|
12
12
|
from .browser import register_browser_tools, is_browser_available
|
|
13
13
|
from .ui import register_ui_tools, is_ui_available
|
|
14
|
+
from .gitops import register_gitops_tools
|
|
15
|
+
from .certs import register_certs_tools
|
|
16
|
+
from .policy import register_policy_tools
|
|
17
|
+
from .backup import register_backup_tools
|
|
18
|
+
from .keda import register_keda_tools
|
|
19
|
+
from .cilium import register_cilium_tools
|
|
20
|
+
from .rollouts import register_rollouts_tools
|
|
21
|
+
from .capi import register_capi_tools
|
|
22
|
+
from .kubevirt import register_kubevirt_tools
|
|
23
|
+
from .kiali import register_istio_tools
|
|
14
24
|
|
|
15
25
|
__all__ = [
|
|
16
26
|
"register_helm_tools",
|
|
@@ -28,4 +38,14 @@ __all__ = [
|
|
|
28
38
|
"is_browser_available",
|
|
29
39
|
"register_ui_tools",
|
|
30
40
|
"is_ui_available",
|
|
41
|
+
"register_gitops_tools",
|
|
42
|
+
"register_certs_tools",
|
|
43
|
+
"register_policy_tools",
|
|
44
|
+
"register_backup_tools",
|
|
45
|
+
"register_keda_tools",
|
|
46
|
+
"register_cilium_tools",
|
|
47
|
+
"register_rollouts_tools",
|
|
48
|
+
"register_capi_tools",
|
|
49
|
+
"register_kubevirt_tools",
|
|
50
|
+
"register_istio_tools",
|
|
31
51
|
]
|