kubectl-mcp-server 1.13.0__py3-none-any.whl → 1.14.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.
@@ -0,0 +1,262 @@
1
+ """Structured error handling for kubectl-mcp-server CLI."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ from typing import List, Optional
6
+
7
+
8
+ class ErrorCode(IntEnum):
9
+ SUCCESS = 0
10
+ CLIENT_ERROR = 1
11
+ SERVER_ERROR = 2
12
+ K8S_ERROR = 3
13
+ BROWSER_ERROR = 4
14
+ NETWORK_ERROR = 5
15
+
16
+
17
+ @dataclass
18
+ class CliError:
19
+ code: ErrorCode
20
+ type: str
21
+ message: str
22
+ details: Optional[str] = None
23
+ suggestion: Optional[str] = None
24
+
25
+ def __str__(self) -> str:
26
+ return format_cli_error(self)
27
+
28
+
29
+ def format_cli_error(error: CliError) -> str:
30
+ lines = [f"Error [{error.type}]: {error.message}"]
31
+ if error.details:
32
+ lines.append(f" Details: {error.details}")
33
+ if error.suggestion:
34
+ lines.append(f" Suggestion: {error.suggestion}")
35
+ return "\n".join(lines)
36
+
37
+
38
+ def tool_not_found_error(name: str, available: Optional[List[str]] = None) -> CliError:
39
+ available = available or []
40
+ available_str = ", ".join(available[:5])
41
+ if len(available) > 5:
42
+ available_str += f" (+{len(available) - 5} more)"
43
+
44
+ return CliError(
45
+ code=ErrorCode.CLIENT_ERROR,
46
+ type="TOOL_NOT_FOUND",
47
+ message=f'Tool "{name}" not found',
48
+ details=f"Available: {available_str}" if available else None,
49
+ suggestion="Run 'kubectl-mcp-server tools' to see all available tools"
50
+ )
51
+
52
+
53
+ def tool_execution_error(name: str, cause: str) -> CliError:
54
+ suggestion = "Check tool arguments match the expected schema"
55
+
56
+ if "validation" in cause.lower() or "invalid" in cause.lower():
57
+ suggestion = f"Run 'kubectl-mcp-server tools {name}' to see the input schema"
58
+ elif "required" in cause.lower():
59
+ suggestion = f"Missing required argument. Run 'kubectl-mcp-server tools {name}' for schema"
60
+ elif "permission" in cause.lower() or "denied" in cause.lower():
61
+ suggestion = "Permission denied. Check RBAC permissions in your cluster"
62
+
63
+ return CliError(
64
+ code=ErrorCode.SERVER_ERROR,
65
+ type="TOOL_EXECUTION_FAILED",
66
+ message=f'Tool "{name}" execution failed',
67
+ details=cause,
68
+ suggestion=suggestion
69
+ )
70
+
71
+
72
+ def k8s_connection_error(context: str, cause: str) -> CliError:
73
+ suggestion = "Check your kubeconfig and ensure the cluster is accessible"
74
+
75
+ if "refused" in cause.lower():
76
+ suggestion = "Connection refused. Is the cluster running? Check 'kubectl cluster-info'"
77
+ elif "timeout" in cause.lower():
78
+ suggestion = "Connection timed out. Check network connectivity to the cluster"
79
+ elif "unauthorized" in cause.lower() or "401" in cause:
80
+ suggestion = "Authentication failed. Check your credentials with 'kubectl auth whoami'"
81
+
82
+ return CliError(
83
+ code=ErrorCode.K8S_ERROR,
84
+ type="K8S_CONNECTION_FAILED",
85
+ message=f'Failed to connect to Kubernetes context "{context}"',
86
+ details=cause,
87
+ suggestion=suggestion
88
+ )
89
+
90
+
91
+ def k8s_context_error(context: str, available: Optional[List[str]] = None) -> CliError:
92
+ available = available or []
93
+ available_str = ", ".join(available[:5]) if available else "(none)"
94
+
95
+ return CliError(
96
+ code=ErrorCode.K8S_ERROR,
97
+ type="K8S_CONTEXT_NOT_FOUND",
98
+ message=f'Kubernetes context "{context}" not found',
99
+ details=f"Available contexts: {available_str}",
100
+ suggestion="Run 'kubectl config get-contexts' to list available contexts"
101
+ )
102
+
103
+
104
+ def k8s_resource_error(resource: str, namespace: str, cause: str) -> CliError:
105
+ return CliError(
106
+ code=ErrorCode.K8S_ERROR,
107
+ type="K8S_RESOURCE_ERROR",
108
+ message=f'Failed to access {resource} in namespace "{namespace}"',
109
+ details=cause,
110
+ suggestion="Check if the resource exists and you have permission to access it"
111
+ )
112
+
113
+
114
+ def browser_not_found_error() -> CliError:
115
+ return CliError(
116
+ code=ErrorCode.BROWSER_ERROR,
117
+ type="BROWSER_NOT_FOUND",
118
+ message="agent-browser CLI not found",
119
+ details="Browser automation tools require agent-browser to be installed",
120
+ suggestion="Install with: npm install -g agent-browser && agent-browser install"
121
+ )
122
+
123
+
124
+ def browser_not_enabled_error() -> CliError:
125
+ return CliError(
126
+ code=ErrorCode.BROWSER_ERROR,
127
+ type="BROWSER_NOT_ENABLED",
128
+ message="Browser tools are not enabled",
129
+ details="Set MCP_BROWSER_ENABLED=true to enable browser automation",
130
+ suggestion="Export MCP_BROWSER_ENABLED=true and ensure agent-browser is installed"
131
+ )
132
+
133
+
134
+ def browser_connection_error(cause: str) -> CliError:
135
+ suggestion = "Check browser configuration and try again"
136
+
137
+ if "ECONNREFUSED" in cause:
138
+ suggestion = "Connection refused. Is the browser running? Try 'agent-browser install'"
139
+ elif "timeout" in cause.lower():
140
+ suggestion = "Connection timed out. Try increasing MCP_BROWSER_TIMEOUT"
141
+ elif "CDP" in cause or "websocket" in cause.lower():
142
+ suggestion = "CDP connection failed. Check MCP_BROWSER_CDP_URL is correct"
143
+
144
+ return CliError(
145
+ code=ErrorCode.BROWSER_ERROR,
146
+ type="BROWSER_CONNECTION_FAILED",
147
+ message="Failed to connect to browser",
148
+ details=cause,
149
+ suggestion=suggestion
150
+ )
151
+
152
+
153
+ def browser_timeout_error(timeout: int) -> CliError:
154
+ return CliError(
155
+ code=ErrorCode.BROWSER_ERROR,
156
+ type="BROWSER_TIMEOUT",
157
+ message=f"Browser command timed out after {timeout}s",
158
+ details="The command took too long to complete",
159
+ suggestion="Increase timeout or check if the page is loading correctly"
160
+ )
161
+
162
+
163
+ def invalid_json_error(input_str: str, parse_error: str) -> CliError:
164
+ truncated = input_str[:100] + "..." if len(input_str) > 100 else input_str
165
+
166
+ return CliError(
167
+ code=ErrorCode.CLIENT_ERROR,
168
+ type="INVALID_JSON",
169
+ message="Invalid JSON in arguments",
170
+ details=f"Parse error: {parse_error}",
171
+ suggestion=f"Use valid JSON format: '{{\"key\": \"value\"}}'. Input was: {truncated}"
172
+ )
173
+
174
+
175
+ def missing_argument_error(command: str, argument: str) -> CliError:
176
+ suggestions = {
177
+ "call": "Use 'kubectl-mcp-server call <tool> '{\"key\": \"value\"}'",
178
+ "tools": "Use 'kubectl-mcp-server tools <name>' to inspect a tool",
179
+ "grep": "Use 'kubectl-mcp-server grep \"*pattern*\"' to search tools",
180
+ "context": "Use 'kubectl-mcp-server context <name>' to switch context",
181
+ }
182
+
183
+ return CliError(
184
+ code=ErrorCode.CLIENT_ERROR,
185
+ type="MISSING_ARGUMENT",
186
+ message=f"Missing required argument: {argument}",
187
+ details=f"Command '{command}' requires {argument}",
188
+ suggestion=suggestions.get(command, "Run 'kubectl-mcp-server --help' for usage")
189
+ )
190
+
191
+
192
+ def unknown_subcommand_error(subcommand: str) -> CliError:
193
+ valid_commands = "serve, tools, resources, prompts, call, grep, info, context, doctor, version, diagnostics"
194
+
195
+ suggestions = {
196
+ "run": "call",
197
+ "exec": "call",
198
+ "execute": "call",
199
+ "invoke": "call",
200
+ "list": "tools",
201
+ "ls": "tools",
202
+ "get": "tools",
203
+ "show": "info",
204
+ "describe": "tools",
205
+ "search": "grep",
206
+ "find": "grep",
207
+ "check": "doctor",
208
+ "status": "info",
209
+ "start": "serve",
210
+ }
211
+
212
+ suggested = suggestions.get(subcommand.lower())
213
+ suggestion_text = (
214
+ f"Did you mean 'kubectl-mcp-server {suggested}'?"
215
+ if suggested
216
+ else "Run 'kubectl-mcp-server --help' for available commands"
217
+ )
218
+
219
+ return CliError(
220
+ code=ErrorCode.CLIENT_ERROR,
221
+ type="UNKNOWN_SUBCOMMAND",
222
+ message=f'Unknown subcommand: "{subcommand}"',
223
+ details=f"Valid commands: {valid_commands}",
224
+ suggestion=suggestion_text
225
+ )
226
+
227
+
228
+ def config_error(message: str, suggestion: Optional[str] = None) -> CliError:
229
+ return CliError(
230
+ code=ErrorCode.CLIENT_ERROR,
231
+ type="CONFIG_ERROR",
232
+ message=message,
233
+ suggestion=suggestion or "Check your configuration and try again"
234
+ )
235
+
236
+
237
+ def dependency_missing_error(dependency: str, install_cmd: Optional[str] = None) -> CliError:
238
+ return CliError(
239
+ code=ErrorCode.CLIENT_ERROR,
240
+ type="DEPENDENCY_MISSING",
241
+ message=f"Required dependency not found: {dependency}",
242
+ suggestion=f"Install with: {install_cmd}" if install_cmd else f"Please install {dependency}"
243
+ )
244
+
245
+
246
+ def network_error(cause: str) -> CliError:
247
+ suggestion = "Check your network connection and try again"
248
+
249
+ if "ECONNREFUSED" in cause:
250
+ suggestion = "Connection refused. Check if the service is running"
251
+ elif "ETIMEDOUT" in cause or "timeout" in cause.lower():
252
+ suggestion = "Connection timed out. Check network connectivity"
253
+ elif "ENOTFOUND" in cause or "DNS" in cause:
254
+ suggestion = "DNS resolution failed. Check the hostname"
255
+
256
+ return CliError(
257
+ code=ErrorCode.NETWORK_ERROR,
258
+ type="NETWORK_ERROR",
259
+ message="Network error occurred",
260
+ details=cause,
261
+ suggestion=suggestion
262
+ )
@@ -0,0 +1,377 @@
1
+ """Output formatting utilities for kubectl-mcp-server CLI."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ COLORS = {
10
+ "reset": "\x1b[0m",
11
+ "bold": "\x1b[1m",
12
+ "dim": "\x1b[2m",
13
+ "italic": "\x1b[3m",
14
+ "underline": "\x1b[4m",
15
+ # Foreground colors
16
+ "black": "\x1b[30m",
17
+ "red": "\x1b[31m",
18
+ "green": "\x1b[32m",
19
+ "yellow": "\x1b[33m",
20
+ "blue": "\x1b[34m",
21
+ "magenta": "\x1b[35m",
22
+ "cyan": "\x1b[36m",
23
+ "white": "\x1b[37m",
24
+ # Bright colors
25
+ "bright_black": "\x1b[90m",
26
+ "bright_red": "\x1b[91m",
27
+ "bright_green": "\x1b[92m",
28
+ "bright_yellow": "\x1b[93m",
29
+ "bright_blue": "\x1b[94m",
30
+ "bright_magenta": "\x1b[95m",
31
+ "bright_cyan": "\x1b[96m",
32
+ "bright_white": "\x1b[97m",
33
+ }
34
+
35
+
36
+ def should_colorize() -> bool:
37
+ if os.environ.get("NO_COLOR"):
38
+ return False
39
+ return sys.stdout.isatty()
40
+
41
+
42
+ def color(text: str, *codes: str) -> str:
43
+ if not should_colorize():
44
+ return text
45
+ combined = "".join(codes)
46
+ return f"{combined}{text}{COLORS['reset']}"
47
+
48
+
49
+ def bold(text: str) -> str:
50
+ return color(text, COLORS["bold"])
51
+
52
+
53
+ def dim(text: str) -> str:
54
+ return color(text, COLORS["dim"])
55
+
56
+
57
+ def cyan(text: str) -> str:
58
+ return color(text, COLORS["cyan"])
59
+
60
+
61
+ def green(text: str) -> str:
62
+ return color(text, COLORS["green"])
63
+
64
+
65
+ def yellow(text: str) -> str:
66
+ return color(text, COLORS["yellow"])
67
+
68
+
69
+ def red(text: str) -> str:
70
+ return color(text, COLORS["red"])
71
+
72
+
73
+ def blue(text: str) -> str:
74
+ return color(text, COLORS["blue"])
75
+
76
+
77
+ TOOL_CATEGORIES = {
78
+ "pods": "Pods",
79
+ "deployments": "Deployments & Workloads",
80
+ "core": "Core Resources",
81
+ "cluster": "Cluster Management",
82
+ "networking": "Networking",
83
+ "storage": "Storage",
84
+ "security": "Security & RBAC",
85
+ "helm": "Helm",
86
+ "operations": "Operations",
87
+ "diagnostics": "Diagnostics",
88
+ "cost": "Cost Optimization",
89
+ "browser": "Browser Automation",
90
+ "ui": "UI Dashboards",
91
+ }
92
+
93
+
94
+ def format_tools_list(
95
+ tools: List[Dict[str, Any]],
96
+ with_descriptions: bool = False,
97
+ as_json: bool = False
98
+ ) -> str:
99
+ if as_json:
100
+ return json.dumps(tools, indent=2)
101
+
102
+ # Group tools by category
103
+ categories: Dict[str, List[Dict]] = {}
104
+ for tool in tools:
105
+ cat = tool.get("category", "other")
106
+ cat_display = TOOL_CATEGORIES.get(cat, cat.title())
107
+ categories.setdefault(cat_display, []).append(tool)
108
+
109
+ lines = []
110
+ for cat, cat_tools in sorted(categories.items()):
111
+ # Category header
112
+ header = f"{cat} ({len(cat_tools)})"
113
+ lines.append(color(header, COLORS["bold"], COLORS["cyan"]))
114
+
115
+ # Tools in category
116
+ for t in sorted(cat_tools, key=lambda x: x.get("name", "")):
117
+ name = t.get("name", "unknown")
118
+ if with_descriptions and t.get("description"):
119
+ desc = t["description"]
120
+ # Truncate long descriptions
121
+ if len(desc) > 60:
122
+ desc = desc[:57] + "..."
123
+ lines.append(f" {green(name)} - {dim(desc)}")
124
+ else:
125
+ lines.append(f" {name}")
126
+
127
+ lines.append("") # Empty line between categories
128
+
129
+ return "\n".join(lines).rstrip()
130
+
131
+
132
+ def format_tool_schema(tool: Dict[str, Any], as_json: bool = False) -> str:
133
+ if as_json:
134
+ return json.dumps(tool, indent=2)
135
+
136
+ lines = []
137
+ name = tool.get("name", "unknown")
138
+ description = tool.get("description", "")
139
+ schema = tool.get("inputSchema", tool.get("input_schema", {}))
140
+
141
+ # Header
142
+ lines.append(f"{bold('Tool:')} {green(name)}")
143
+ if description:
144
+ lines.append(f"{bold('Description:')} {description}")
145
+
146
+ # Parameters
147
+ properties = schema.get("properties", {})
148
+ required = schema.get("required", [])
149
+
150
+ if properties:
151
+ lines.append("")
152
+ lines.append(bold("Parameters:"))
153
+ for param_name, param_info in properties.items():
154
+ param_type = param_info.get("type", "any")
155
+ param_desc = param_info.get("description", "")
156
+ is_required = param_name in required
157
+ req_str = yellow("required") if is_required else dim("optional")
158
+
159
+ param_line = f" {cyan(param_name)} ({param_type}, {req_str})"
160
+ if param_desc:
161
+ lines.append(f"{param_line}")
162
+ lines.append(f" {dim(param_desc)}")
163
+ else:
164
+ lines.append(param_line)
165
+
166
+ # Full schema
167
+ lines.append("")
168
+ lines.append(bold("Input Schema:"))
169
+ lines.append(json.dumps(schema, indent=2))
170
+
171
+ return "\n".join(lines)
172
+
173
+
174
+ def format_tools_search(
175
+ results: List[Dict[str, Any]],
176
+ pattern: str,
177
+ with_descriptions: bool = False
178
+ ) -> str:
179
+ if not results:
180
+ return dim(f"No tools matching '{pattern}'")
181
+
182
+ lines = [f"Found {len(results)} tools matching '{pattern}':", ""]
183
+
184
+ for tool in results:
185
+ name = tool.get("name", "unknown")
186
+ if with_descriptions and tool.get("description"):
187
+ lines.append(f"{green(name)} - {dim(tool['description'])}")
188
+ else:
189
+ lines.append(green(name))
190
+
191
+ return "\n".join(lines)
192
+
193
+
194
+ def format_resources_list(
195
+ resources: List[Dict[str, Any]],
196
+ as_json: bool = False
197
+ ) -> str:
198
+ if as_json:
199
+ return json.dumps(resources, indent=2)
200
+
201
+ lines = [bold(f"Resources ({len(resources)}):"), ""]
202
+
203
+ for res in resources:
204
+ uri = res.get("uri", res.get("name", "unknown"))
205
+ name = res.get("name", uri)
206
+ description = res.get("description", "")
207
+
208
+ lines.append(f" {cyan(uri)}")
209
+ if name != uri:
210
+ lines.append(f" Name: {name}")
211
+ if description:
212
+ lines.append(f" {dim(description)}")
213
+
214
+ return "\n".join(lines)
215
+
216
+
217
+ def format_prompts_list(
218
+ prompts: List[Dict[str, Any]],
219
+ as_json: bool = False
220
+ ) -> str:
221
+ if as_json:
222
+ return json.dumps(prompts, indent=2)
223
+
224
+ lines = [bold(f"Prompts ({len(prompts)}):"), ""]
225
+
226
+ for prompt in prompts:
227
+ name = prompt.get("name", "unknown")
228
+ description = prompt.get("description", "")
229
+ args = prompt.get("arguments", [])
230
+
231
+ lines.append(f" {green(name)}")
232
+ if description:
233
+ lines.append(f" {dim(description)}")
234
+ if args:
235
+ arg_names = [a.get("name", "?") for a in args]
236
+ lines.append(f" Arguments: {', '.join(arg_names)}")
237
+
238
+ return "\n".join(lines)
239
+
240
+
241
+ def format_call_result(result: Any, as_json: bool = False) -> str:
242
+ if as_json or not sys.stdout.isatty():
243
+ return json.dumps(result, indent=2, default=str)
244
+
245
+ # Try to extract text content from MCP result format
246
+ if isinstance(result, dict):
247
+ content = result.get("content", [])
248
+ if isinstance(content, list):
249
+ text_parts = []
250
+ for item in content:
251
+ if isinstance(item, dict) and item.get("type") == "text":
252
+ text_parts.append(item.get("text", ""))
253
+ if text_parts:
254
+ return "\n".join(text_parts)
255
+
256
+ if result.get("isError"):
257
+ error_msg = result.get("error", result.get("message", "Unknown error"))
258
+ return red(f"Error: {error_msg}")
259
+
260
+ return json.dumps(result, indent=2, default=str)
261
+
262
+
263
+ def format_server_info(
264
+ version: str,
265
+ tool_count: int,
266
+ resource_count: int,
267
+ prompt_count: int,
268
+ context: Optional[str] = None,
269
+ as_json: bool = False
270
+ ) -> str:
271
+ info = {
272
+ "version": version,
273
+ "tools": tool_count,
274
+ "resources": resource_count,
275
+ "prompts": prompt_count,
276
+ "k8s_context": context,
277
+ }
278
+
279
+ if as_json:
280
+ return json.dumps(info, indent=2)
281
+
282
+ lines = [
283
+ bold("kubectl-mcp-server"),
284
+ "",
285
+ f" {cyan('Version:')} {version}",
286
+ f" {cyan('Tools:')} {tool_count}",
287
+ f" {cyan('Resources:')} {resource_count}",
288
+ f" {cyan('Prompts:')} {prompt_count}",
289
+ ]
290
+
291
+ if context:
292
+ lines.append(f" {cyan('K8s Context:')} {context}")
293
+
294
+ return "\n".join(lines)
295
+
296
+
297
+ def format_context_info(
298
+ current: str,
299
+ available: List[str],
300
+ as_json: bool = False
301
+ ) -> str:
302
+ if as_json:
303
+ return json.dumps({
304
+ "current": current,
305
+ "available": available
306
+ }, indent=2)
307
+
308
+ lines = [
309
+ f"{bold('Current context:')} {green(current)}",
310
+ "",
311
+ bold("Available contexts:"),
312
+ ]
313
+
314
+ for ctx in available:
315
+ if ctx == current:
316
+ lines.append(f" {green('*')} {green(ctx)} (current)")
317
+ else:
318
+ lines.append(f" {ctx}")
319
+
320
+ return "\n".join(lines)
321
+
322
+
323
+ def format_doctor_results(
324
+ checks: List[Dict[str, Any]],
325
+ as_json: bool = False
326
+ ) -> str:
327
+ if as_json:
328
+ return json.dumps(checks, indent=2)
329
+
330
+ lines = [bold("Checking dependencies..."), ""]
331
+ all_passed = True
332
+
333
+ for check in checks:
334
+ name = check.get("name", "unknown")
335
+ status = check.get("status", "unknown")
336
+ details = check.get("details", "")
337
+ version = check.get("version", "")
338
+
339
+ if status == "ok":
340
+ icon = green("✓")
341
+ status_text = green("OK")
342
+ elif status == "warning":
343
+ icon = yellow("!")
344
+ status_text = yellow("WARNING")
345
+ all_passed = False
346
+ else:
347
+ icon = red("✗")
348
+ status_text = red("FAILED")
349
+ all_passed = False
350
+
351
+ line = f" {icon} {name}: {status_text}"
352
+ if version:
353
+ line += f" ({version})"
354
+ lines.append(line)
355
+
356
+ if details and status != "ok":
357
+ lines.append(f" {dim(details)}")
358
+
359
+ lines.append("")
360
+ if all_passed:
361
+ lines.append(green("All checks passed!"))
362
+ else:
363
+ lines.append(yellow("Some checks failed. See details above."))
364
+
365
+ return "\n".join(lines)
366
+
367
+
368
+ def format_error(message: str) -> str:
369
+ return red(f"Error: {message}")
370
+
371
+
372
+ def format_warning(message: str) -> str:
373
+ return yellow(f"Warning: {message}")
374
+
375
+
376
+ def format_success(message: str) -> str:
377
+ return green(message)