kubectl-mcp-server 1.16.0__py3-none-any.whl → 1.18.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.
Files changed (30) hide show
  1. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/METADATA +48 -1
  2. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/RECORD +30 -15
  3. kubectl_mcp_tool/__init__.py +1 -1
  4. kubectl_mcp_tool/cli/cli.py +83 -9
  5. kubectl_mcp_tool/cli/output.py +14 -0
  6. kubectl_mcp_tool/config/__init__.py +46 -0
  7. kubectl_mcp_tool/config/loader.py +386 -0
  8. kubectl_mcp_tool/config/schema.py +184 -0
  9. kubectl_mcp_tool/k8s_config.py +127 -1
  10. kubectl_mcp_tool/mcp_server.py +219 -8
  11. kubectl_mcp_tool/observability/__init__.py +59 -0
  12. kubectl_mcp_tool/observability/metrics.py +223 -0
  13. kubectl_mcp_tool/observability/stats.py +255 -0
  14. kubectl_mcp_tool/observability/tracing.py +335 -0
  15. kubectl_mcp_tool/prompts/__init__.py +43 -0
  16. kubectl_mcp_tool/prompts/builtin.py +695 -0
  17. kubectl_mcp_tool/prompts/custom.py +298 -0
  18. kubectl_mcp_tool/prompts/prompts.py +180 -4
  19. kubectl_mcp_tool/providers.py +347 -0
  20. kubectl_mcp_tool/safety.py +155 -0
  21. kubectl_mcp_tool/tools/cluster.py +384 -0
  22. tests/test_config.py +386 -0
  23. tests/test_mcp_integration.py +251 -0
  24. tests/test_observability.py +521 -0
  25. tests/test_prompts.py +716 -0
  26. tests/test_safety.py +218 -0
  27. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/WHEEL +0 -0
  28. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/entry_points.txt +0 -0
  29. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.0.dist-info}/licenses/LICENSE +0 -0
  30. {kubectl_mcp_server-1.16.0.dist-info → kubectl_mcp_server-1.18.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
- from typing import Optional
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
- def register_prompts(server):
8
- """Register all MCP prompts for Kubernetes workflows.
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 × {target_replicas} = Total needed
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