kubectl-mcp-server 1.12.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.12.0.dist-info/METADATA +711 -0
- kubectl_mcp_server-1.12.0.dist-info/RECORD +45 -0
- kubectl_mcp_server-1.12.0.dist-info/WHEEL +5 -0
- kubectl_mcp_server-1.12.0.dist-info/entry_points.txt +3 -0
- kubectl_mcp_server-1.12.0.dist-info/licenses/LICENSE +21 -0
- kubectl_mcp_server-1.12.0.dist-info/top_level.txt +2 -0
- kubectl_mcp_tool/__init__.py +21 -0
- kubectl_mcp_tool/__main__.py +46 -0
- kubectl_mcp_tool/auth/__init__.py +13 -0
- kubectl_mcp_tool/auth/config.py +71 -0
- kubectl_mcp_tool/auth/scopes.py +148 -0
- kubectl_mcp_tool/auth/verifier.py +82 -0
- kubectl_mcp_tool/cli/__init__.py +9 -0
- kubectl_mcp_tool/cli/__main__.py +10 -0
- kubectl_mcp_tool/cli/cli.py +111 -0
- kubectl_mcp_tool/diagnostics.py +355 -0
- kubectl_mcp_tool/k8s_config.py +289 -0
- kubectl_mcp_tool/mcp_server.py +530 -0
- kubectl_mcp_tool/prompts/__init__.py +5 -0
- kubectl_mcp_tool/prompts/prompts.py +823 -0
- kubectl_mcp_tool/resources/__init__.py +5 -0
- kubectl_mcp_tool/resources/resources.py +305 -0
- kubectl_mcp_tool/tools/__init__.py +28 -0
- kubectl_mcp_tool/tools/browser.py +371 -0
- kubectl_mcp_tool/tools/cluster.py +315 -0
- kubectl_mcp_tool/tools/core.py +421 -0
- kubectl_mcp_tool/tools/cost.py +680 -0
- kubectl_mcp_tool/tools/deployments.py +381 -0
- kubectl_mcp_tool/tools/diagnostics.py +174 -0
- kubectl_mcp_tool/tools/helm.py +1561 -0
- kubectl_mcp_tool/tools/networking.py +296 -0
- kubectl_mcp_tool/tools/operations.py +501 -0
- kubectl_mcp_tool/tools/pods.py +582 -0
- kubectl_mcp_tool/tools/security.py +333 -0
- kubectl_mcp_tool/tools/storage.py +133 -0
- kubectl_mcp_tool/utils/__init__.py +17 -0
- kubectl_mcp_tool/utils/helpers.py +80 -0
- tests/__init__.py +9 -0
- tests/conftest.py +379 -0
- tests/test_auth.py +256 -0
- tests/test_browser.py +349 -0
- tests/test_prompts.py +536 -0
- tests/test_resources.py +343 -0
- tests/test_server.py +384 -0
- tests/test_tools.py +659 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Browser automation tools using agent-browser (optional module)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from mcp.types import ToolAnnotations
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("mcp-server")
|
|
13
|
+
|
|
14
|
+
BROWSER_ENABLED = os.environ.get("MCP_BROWSER_ENABLED", "").lower() in ("1", "true")
|
|
15
|
+
BROWSER_AVAILABLE = shutil.which("agent-browser") is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_browser_available() -> bool:
|
|
19
|
+
"""Check if browser tools should be registered."""
|
|
20
|
+
if not BROWSER_ENABLED:
|
|
21
|
+
return False
|
|
22
|
+
if not BROWSER_AVAILABLE:
|
|
23
|
+
logger.warning("MCP_BROWSER_ENABLED=true but agent-browser not found in PATH")
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _run_browser(args: list, timeout: int = 60) -> Dict[str, Any]:
|
|
29
|
+
"""Execute agent-browser command."""
|
|
30
|
+
cmd = ["agent-browser"] + args
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
33
|
+
if result.returncode != 0:
|
|
34
|
+
return {"success": False, "error": result.stderr.strip() or "Command failed"}
|
|
35
|
+
output = result.stdout.strip()
|
|
36
|
+
if "--json" in args:
|
|
37
|
+
try:
|
|
38
|
+
return {"success": True, "data": json.loads(output)}
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return {"success": True, "output": output}
|
|
41
|
+
return {"success": True, "output": output}
|
|
42
|
+
except subprocess.TimeoutExpired:
|
|
43
|
+
return {"success": False, "error": f"Command timed out after {timeout}s"}
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
return {"success": False, "error": "agent-browser not found"}
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return {"success": False, "error": str(e)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_ingress_url(service: str, namespace: str) -> Optional[str]:
|
|
51
|
+
"""Get ingress URL for a service."""
|
|
52
|
+
try:
|
|
53
|
+
from kubernetes import client, config
|
|
54
|
+
config.load_kube_config()
|
|
55
|
+
networking = client.NetworkingV1Api()
|
|
56
|
+
ingresses = networking.list_namespaced_ingress(namespace)
|
|
57
|
+
for ing in ingresses.items:
|
|
58
|
+
for rule in ing.spec.rules or []:
|
|
59
|
+
for path in (rule.http.paths if rule.http else []):
|
|
60
|
+
backend = path.backend
|
|
61
|
+
if backend.service and backend.service.name == service:
|
|
62
|
+
host = rule.host or ing.status.load_balancer.ingress[0].hostname
|
|
63
|
+
scheme = "https" if ing.spec.tls else "http"
|
|
64
|
+
return f"{scheme}://{host}"
|
|
65
|
+
return None
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_service_url(service: str, namespace: str) -> Optional[str]:
|
|
71
|
+
"""Get service URL (LoadBalancer or NodePort)."""
|
|
72
|
+
try:
|
|
73
|
+
from kubernetes import client, config
|
|
74
|
+
config.load_kube_config()
|
|
75
|
+
v1 = client.CoreV1Api()
|
|
76
|
+
svc = v1.read_namespaced_service(service, namespace)
|
|
77
|
+
if svc.spec.type == "LoadBalancer":
|
|
78
|
+
ingress = svc.status.load_balancer.ingress
|
|
79
|
+
if ingress:
|
|
80
|
+
host = ingress[0].hostname or ingress[0].ip
|
|
81
|
+
port = svc.spec.ports[0].port
|
|
82
|
+
return f"http://{host}:{port}"
|
|
83
|
+
elif svc.spec.type == "NodePort":
|
|
84
|
+
node_port = svc.spec.ports[0].node_port
|
|
85
|
+
return f"http://localhost:{node_port}"
|
|
86
|
+
return None
|
|
87
|
+
except Exception:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def register_browser_tools(server, non_destructive: bool):
|
|
92
|
+
"""Register browser automation tools."""
|
|
93
|
+
|
|
94
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Open URL", readOnlyHint=True))
|
|
95
|
+
def browser_open(url: str, wait_for: str = "networkidle") -> Dict[str, Any]:
|
|
96
|
+
"""Open a URL in the browser."""
|
|
97
|
+
result = _run_browser(["open", url])
|
|
98
|
+
if result.get("success") and wait_for:
|
|
99
|
+
_run_browser(["wait", "--load", wait_for])
|
|
100
|
+
return {**result, "url": url}
|
|
101
|
+
|
|
102
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Snapshot", readOnlyHint=True))
|
|
103
|
+
def browser_snapshot(interactive_only: bool = True, compact: bool = True, depth: Optional[int] = None) -> Dict[str, Any]:
|
|
104
|
+
"""Get accessibility tree snapshot of current page."""
|
|
105
|
+
args = ["snapshot", "--json"]
|
|
106
|
+
if interactive_only:
|
|
107
|
+
args.append("-i")
|
|
108
|
+
if compact:
|
|
109
|
+
args.append("-c")
|
|
110
|
+
if depth:
|
|
111
|
+
args.extend(["-d", str(depth)])
|
|
112
|
+
return _run_browser(args)
|
|
113
|
+
|
|
114
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Click"))
|
|
115
|
+
def browser_click(ref: str) -> Dict[str, Any]:
|
|
116
|
+
"""Click an element by ref (from snapshot)."""
|
|
117
|
+
return _run_browser(["click", ref])
|
|
118
|
+
|
|
119
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Fill"))
|
|
120
|
+
def browser_fill(ref: str, text: str) -> Dict[str, Any]:
|
|
121
|
+
"""Fill a form field by ref."""
|
|
122
|
+
return _run_browser(["fill", ref, text])
|
|
123
|
+
|
|
124
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Screenshot", readOnlyHint=True))
|
|
125
|
+
def browser_screenshot(output_path: str = "/tmp/screenshot.png", full_page: bool = False) -> Dict[str, Any]:
|
|
126
|
+
"""Take a screenshot of the current page."""
|
|
127
|
+
args = ["screenshot", output_path]
|
|
128
|
+
if full_page:
|
|
129
|
+
args.append("--full")
|
|
130
|
+
result = _run_browser(args)
|
|
131
|
+
return {**result, "path": output_path}
|
|
132
|
+
|
|
133
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Get Text", readOnlyHint=True))
|
|
134
|
+
def browser_get_text(ref: str) -> Dict[str, Any]:
|
|
135
|
+
"""Get text content of an element."""
|
|
136
|
+
return _run_browser(["get", "text", ref])
|
|
137
|
+
|
|
138
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Get URL", readOnlyHint=True))
|
|
139
|
+
def browser_get_url() -> Dict[str, Any]:
|
|
140
|
+
"""Get current page URL."""
|
|
141
|
+
return _run_browser(["get", "url"])
|
|
142
|
+
|
|
143
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Wait"))
|
|
144
|
+
def browser_wait(selector: Optional[str] = None, text: Optional[str] = None, timeout_ms: int = 5000) -> Dict[str, Any]:
|
|
145
|
+
"""Wait for element, text, or timeout."""
|
|
146
|
+
if text:
|
|
147
|
+
return _run_browser(["wait", "--text", text], timeout=timeout_ms // 1000 + 5)
|
|
148
|
+
elif selector:
|
|
149
|
+
return _run_browser(["wait", selector], timeout=timeout_ms // 1000 + 5)
|
|
150
|
+
else:
|
|
151
|
+
return _run_browser(["wait", str(timeout_ms)])
|
|
152
|
+
|
|
153
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Close"))
|
|
154
|
+
def browser_close() -> Dict[str, Any]:
|
|
155
|
+
"""Close the browser."""
|
|
156
|
+
return _run_browser(["close"])
|
|
157
|
+
|
|
158
|
+
@server.tool(annotations=ToolAnnotations(title="Test K8s Ingress", readOnlyHint=True))
|
|
159
|
+
def browser_test_ingress(
|
|
160
|
+
service_name: str,
|
|
161
|
+
namespace: str = "default",
|
|
162
|
+
path: str = "/",
|
|
163
|
+
expected_text: Optional[str] = None
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
"""Test a Kubernetes service via its Ingress URL."""
|
|
166
|
+
url = _get_ingress_url(service_name, namespace)
|
|
167
|
+
if not url:
|
|
168
|
+
url = _get_service_url(service_name, namespace)
|
|
169
|
+
if not url:
|
|
170
|
+
return {"success": False, "error": f"No external URL found for {service_name} in {namespace}"}
|
|
171
|
+
|
|
172
|
+
full_url = f"{url}{path}"
|
|
173
|
+
open_result = _run_browser(["open", full_url])
|
|
174
|
+
if not open_result.get("success"):
|
|
175
|
+
return {**open_result, "url": full_url}
|
|
176
|
+
|
|
177
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
178
|
+
snapshot = _run_browser(["snapshot", "-i", "--json"])
|
|
179
|
+
|
|
180
|
+
result = {
|
|
181
|
+
"success": True,
|
|
182
|
+
"url": full_url,
|
|
183
|
+
"service": service_name,
|
|
184
|
+
"namespace": namespace,
|
|
185
|
+
"accessible": True,
|
|
186
|
+
"snapshot": snapshot.get("data")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if expected_text:
|
|
190
|
+
text_result = _run_browser(["wait", "--text", expected_text], timeout=10)
|
|
191
|
+
result["expectedTextFound"] = text_result.get("success", False)
|
|
192
|
+
|
|
193
|
+
_run_browser(["close"])
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
@server.tool(annotations=ToolAnnotations(title="Screenshot K8s Service", readOnlyHint=True))
|
|
197
|
+
def browser_screenshot_service(
|
|
198
|
+
service_name: str,
|
|
199
|
+
namespace: str = "default",
|
|
200
|
+
path: str = "/",
|
|
201
|
+
output_path: str = "/tmp/service-screenshot.png",
|
|
202
|
+
full_page: bool = True
|
|
203
|
+
) -> Dict[str, Any]:
|
|
204
|
+
"""Take a screenshot of a Kubernetes service's web UI."""
|
|
205
|
+
url = _get_ingress_url(service_name, namespace)
|
|
206
|
+
if not url:
|
|
207
|
+
url = _get_service_url(service_name, namespace)
|
|
208
|
+
if not url:
|
|
209
|
+
return {"success": False, "error": f"No external URL found for {service_name} in {namespace}"}
|
|
210
|
+
|
|
211
|
+
full_url = f"{url}{path}"
|
|
212
|
+
_run_browser(["open", full_url])
|
|
213
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
214
|
+
_run_browser(["wait", "2000"])
|
|
215
|
+
|
|
216
|
+
args = ["screenshot", output_path]
|
|
217
|
+
if full_page:
|
|
218
|
+
args.append("--full")
|
|
219
|
+
result = _run_browser(args)
|
|
220
|
+
_run_browser(["close"])
|
|
221
|
+
return {**result, "url": full_url, "path": output_path}
|
|
222
|
+
|
|
223
|
+
@server.tool(annotations=ToolAnnotations(title="Screenshot Grafana", readOnlyHint=True))
|
|
224
|
+
def browser_screenshot_grafana(
|
|
225
|
+
grafana_url: str,
|
|
226
|
+
dashboard_uid: Optional[str] = None,
|
|
227
|
+
output_path: str = "/tmp/grafana-dashboard.png"
|
|
228
|
+
) -> Dict[str, Any]:
|
|
229
|
+
"""Take a screenshot of a Grafana dashboard."""
|
|
230
|
+
url = grafana_url
|
|
231
|
+
if dashboard_uid:
|
|
232
|
+
url = f"{grafana_url.rstrip('/')}/d/{dashboard_uid}"
|
|
233
|
+
|
|
234
|
+
_run_browser(["open", url])
|
|
235
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
236
|
+
_run_browser(["wait", "3000"])
|
|
237
|
+
result = _run_browser(["screenshot", "--full", output_path])
|
|
238
|
+
_run_browser(["close"])
|
|
239
|
+
return {**result, "url": url, "path": output_path}
|
|
240
|
+
|
|
241
|
+
@server.tool(annotations=ToolAnnotations(title="Screenshot ArgoCD", readOnlyHint=True))
|
|
242
|
+
def browser_screenshot_argocd(
|
|
243
|
+
argocd_url: str,
|
|
244
|
+
app_name: Optional[str] = None,
|
|
245
|
+
output_path: str = "/tmp/argocd-screenshot.png"
|
|
246
|
+
) -> Dict[str, Any]:
|
|
247
|
+
"""Take a screenshot of ArgoCD application view."""
|
|
248
|
+
url = argocd_url
|
|
249
|
+
if app_name:
|
|
250
|
+
url = f"{argocd_url.rstrip('/')}/applications/{app_name}"
|
|
251
|
+
|
|
252
|
+
_run_browser(["open", url])
|
|
253
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
254
|
+
_run_browser(["wait", "2000"])
|
|
255
|
+
result = _run_browser(["screenshot", "--full", output_path])
|
|
256
|
+
_run_browser(["close"])
|
|
257
|
+
return {**result, "url": url, "path": output_path}
|
|
258
|
+
|
|
259
|
+
@server.tool(annotations=ToolAnnotations(title="Health Check Web App", readOnlyHint=True))
|
|
260
|
+
def browser_health_check(
|
|
261
|
+
url: str,
|
|
262
|
+
expected_status_text: Optional[str] = None,
|
|
263
|
+
check_elements: Optional[list] = None
|
|
264
|
+
) -> Dict[str, Any]:
|
|
265
|
+
"""Perform health check on a web application."""
|
|
266
|
+
open_result = _run_browser(["open", url])
|
|
267
|
+
if not open_result.get("success"):
|
|
268
|
+
return {**open_result, "url": url, "healthy": False}
|
|
269
|
+
|
|
270
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
271
|
+
title_result = _run_browser(["get", "title"])
|
|
272
|
+
url_result = _run_browser(["get", "url"])
|
|
273
|
+
snapshot = _run_browser(["snapshot", "-i", "-c", "--json"])
|
|
274
|
+
|
|
275
|
+
result = {
|
|
276
|
+
"success": True,
|
|
277
|
+
"url": url,
|
|
278
|
+
"finalUrl": url_result.get("output"),
|
|
279
|
+
"title": title_result.get("output"),
|
|
280
|
+
"healthy": True,
|
|
281
|
+
"checks": {}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if expected_status_text:
|
|
285
|
+
text_check = _run_browser(["wait", "--text", expected_status_text], timeout=5)
|
|
286
|
+
result["checks"]["expectedText"] = text_check.get("success", False)
|
|
287
|
+
if not text_check.get("success"):
|
|
288
|
+
result["healthy"] = False
|
|
289
|
+
|
|
290
|
+
if check_elements:
|
|
291
|
+
for elem in check_elements:
|
|
292
|
+
elem_check = _run_browser(["is", "visible", elem])
|
|
293
|
+
result["checks"][elem] = "visible" in elem_check.get("output", "").lower()
|
|
294
|
+
|
|
295
|
+
_run_browser(["close"])
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Form Submit"))
|
|
299
|
+
def browser_form_submit(
|
|
300
|
+
url: str,
|
|
301
|
+
form_data: Dict[str, str],
|
|
302
|
+
submit_ref: Optional[str] = None
|
|
303
|
+
) -> Dict[str, Any]:
|
|
304
|
+
"""Fill and submit a web form."""
|
|
305
|
+
_run_browser(["open", url])
|
|
306
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
307
|
+
|
|
308
|
+
for ref, value in form_data.items():
|
|
309
|
+
fill_result = _run_browser(["fill", ref, value])
|
|
310
|
+
if not fill_result.get("success"):
|
|
311
|
+
_run_browser(["close"])
|
|
312
|
+
return {"success": False, "error": f"Failed to fill {ref}", "details": fill_result}
|
|
313
|
+
|
|
314
|
+
if submit_ref:
|
|
315
|
+
_run_browser(["click", submit_ref])
|
|
316
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
317
|
+
|
|
318
|
+
snapshot = _run_browser(["snapshot", "-i", "--json"])
|
|
319
|
+
final_url = _run_browser(["get", "url"])
|
|
320
|
+
_run_browser(["close"])
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
"success": True,
|
|
324
|
+
"url": url,
|
|
325
|
+
"finalUrl": final_url.get("output"),
|
|
326
|
+
"formData": list(form_data.keys()),
|
|
327
|
+
"snapshot": snapshot.get("data")
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Session Save"))
|
|
331
|
+
def browser_session_save(path: str = "/tmp/browser-state.json") -> Dict[str, Any]:
|
|
332
|
+
"""Save browser session state (cookies, storage)."""
|
|
333
|
+
return _run_browser(["state", "save", path])
|
|
334
|
+
|
|
335
|
+
@server.tool(annotations=ToolAnnotations(title="Browser Session Load"))
|
|
336
|
+
def browser_session_load(path: str = "/tmp/browser-state.json") -> Dict[str, Any]:
|
|
337
|
+
"""Load browser session state."""
|
|
338
|
+
return _run_browser(["state", "load", path])
|
|
339
|
+
|
|
340
|
+
@server.tool(annotations=ToolAnnotations(title="Open Cloud Console", readOnlyHint=True))
|
|
341
|
+
def browser_open_cloud_console(
|
|
342
|
+
provider: str,
|
|
343
|
+
resource_type: str = "clusters",
|
|
344
|
+
region: Optional[str] = None,
|
|
345
|
+
project: Optional[str] = None
|
|
346
|
+
) -> Dict[str, Any]:
|
|
347
|
+
"""Open cloud provider Kubernetes console (eks, gke, aks)."""
|
|
348
|
+
urls = {
|
|
349
|
+
"eks": f"https://{region or 'us-east-1'}.console.aws.amazon.com/eks/home?region={region or 'us-east-1'}#/{resource_type}",
|
|
350
|
+
"gke": f"https://console.cloud.google.com/kubernetes/{resource_type}?project={project or '_'}",
|
|
351
|
+
"aks": "https://portal.azure.com/#browse/Microsoft.ContainerService%2FmanagedClusters",
|
|
352
|
+
"do": "https://cloud.digitalocean.com/kubernetes/clusters",
|
|
353
|
+
}
|
|
354
|
+
url = urls.get(provider.lower())
|
|
355
|
+
if not url:
|
|
356
|
+
return {"success": False, "error": f"Unknown provider: {provider}. Use eks, gke, aks, or do"}
|
|
357
|
+
|
|
358
|
+
result = _run_browser(["open", url])
|
|
359
|
+
return {**result, "provider": provider, "url": url}
|
|
360
|
+
|
|
361
|
+
@server.tool(annotations=ToolAnnotations(title="Browser PDF Export", readOnlyHint=True))
|
|
362
|
+
def browser_pdf_export(url: str, output_path: str = "/tmp/page.pdf") -> Dict[str, Any]:
|
|
363
|
+
"""Export a web page as PDF."""
|
|
364
|
+
_run_browser(["open", url])
|
|
365
|
+
_run_browser(["wait", "--load", "networkidle"])
|
|
366
|
+
_run_browser(["wait", "2000"])
|
|
367
|
+
result = _run_browser(["pdf", output_path])
|
|
368
|
+
_run_browser(["close"])
|
|
369
|
+
return {**result, "url": url, "path": output_path}
|
|
370
|
+
|
|
371
|
+
logger.info("Registered 19 browser automation tools")
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from mcp.types import ToolAnnotations
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("mcp-server")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_cluster_tools(server, non_destructive: bool):
|
|
11
|
+
"""Register cluster and context management tools."""
|
|
12
|
+
|
|
13
|
+
@server.tool(
|
|
14
|
+
annotations=ToolAnnotations(
|
|
15
|
+
title="Switch Context",
|
|
16
|
+
destructiveHint=True,
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
def switch_context(context_name: str) -> Dict[str, Any]:
|
|
20
|
+
"""Switch to a different kubectl context."""
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
["kubectl", "config", "use-context", context_name],
|
|
24
|
+
capture_output=True, text=True, timeout=10
|
|
25
|
+
)
|
|
26
|
+
if result.returncode == 0:
|
|
27
|
+
return {"success": True, "message": f"Switched to context: {context_name}"}
|
|
28
|
+
return {"success": False, "error": result.stderr}
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logger.error(f"Error switching context: {e}")
|
|
31
|
+
return {"success": False, "error": str(e)}
|
|
32
|
+
|
|
33
|
+
@server.tool(
|
|
34
|
+
annotations=ToolAnnotations(
|
|
35
|
+
title="Get Current Context",
|
|
36
|
+
readOnlyHint=True,
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
def get_current_context() -> Dict[str, Any]:
|
|
40
|
+
"""Get the current kubectl context."""
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["kubectl", "config", "current-context"],
|
|
44
|
+
capture_output=True, text=True, timeout=10
|
|
45
|
+
)
|
|
46
|
+
if result.returncode == 0:
|
|
47
|
+
return {"success": True, "context": result.stdout.strip()}
|
|
48
|
+
return {"success": False, "error": result.stderr}
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.error(f"Error getting current context: {e}")
|
|
51
|
+
return {"success": False, "error": str(e)}
|
|
52
|
+
|
|
53
|
+
@server.tool(
|
|
54
|
+
annotations=ToolAnnotations(
|
|
55
|
+
title="List Contexts",
|
|
56
|
+
readOnlyHint=True,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
def list_contexts() -> Dict[str, Any]:
|
|
60
|
+
"""List all available kubectl contexts."""
|
|
61
|
+
try:
|
|
62
|
+
from kubernetes import config
|
|
63
|
+
contexts, active_context = config.list_kube_config_contexts()
|
|
64
|
+
return {
|
|
65
|
+
"success": True,
|
|
66
|
+
"contexts": [
|
|
67
|
+
{
|
|
68
|
+
"name": ctx.get("name"),
|
|
69
|
+
"cluster": ctx.get("context", {}).get("cluster"),
|
|
70
|
+
"user": ctx.get("context", {}).get("user"),
|
|
71
|
+
"namespace": ctx.get("context", {}).get("namespace", "default"),
|
|
72
|
+
"active": ctx.get("name") == (active_context.get("name") if active_context else None)
|
|
73
|
+
}
|
|
74
|
+
for ctx in contexts
|
|
75
|
+
],
|
|
76
|
+
"active_context": active_context.get("name") if active_context else None
|
|
77
|
+
}
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Error listing contexts: {e}")
|
|
80
|
+
return {"success": False, "error": str(e)}
|
|
81
|
+
|
|
82
|
+
@server.tool(
|
|
83
|
+
annotations=ToolAnnotations(
|
|
84
|
+
title="Get Context Details",
|
|
85
|
+
readOnlyHint=True,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
def get_context_details(context_name: str) -> Dict[str, Any]:
|
|
89
|
+
"""Get details about a specific context."""
|
|
90
|
+
try:
|
|
91
|
+
from kubernetes import config
|
|
92
|
+
contexts, _ = config.list_kube_config_contexts()
|
|
93
|
+
|
|
94
|
+
for ctx in contexts:
|
|
95
|
+
if ctx.get("name") == context_name:
|
|
96
|
+
return {
|
|
97
|
+
"success": True,
|
|
98
|
+
"context": {
|
|
99
|
+
"name": ctx.get("name"),
|
|
100
|
+
"cluster": ctx.get("context", {}).get("cluster"),
|
|
101
|
+
"user": ctx.get("context", {}).get("user"),
|
|
102
|
+
"namespace": ctx.get("context", {}).get("namespace", "default")
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {"success": False, "error": f"Context '{context_name}' not found"}
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Error getting context details: {e}")
|
|
109
|
+
return {"success": False, "error": str(e)}
|
|
110
|
+
|
|
111
|
+
@server.tool(
|
|
112
|
+
annotations=ToolAnnotations(
|
|
113
|
+
title="Set Namespace for Context",
|
|
114
|
+
destructiveHint=True,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
def set_namespace_for_context(namespace: str, context_name: Optional[str] = None) -> Dict[str, Any]:
|
|
118
|
+
"""Set the default namespace for a context."""
|
|
119
|
+
try:
|
|
120
|
+
cmd = ["kubectl", "config", "set-context"]
|
|
121
|
+
if context_name:
|
|
122
|
+
cmd.append(context_name)
|
|
123
|
+
else:
|
|
124
|
+
cmd.append("--current")
|
|
125
|
+
cmd.extend(["--namespace", namespace])
|
|
126
|
+
|
|
127
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
|
128
|
+
if result.returncode == 0:
|
|
129
|
+
return {"success": True, "message": f"Namespace set to: {namespace}"}
|
|
130
|
+
return {"success": False, "error": result.stderr}
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.error(f"Error setting namespace: {e}")
|
|
133
|
+
return {"success": False, "error": str(e)}
|
|
134
|
+
|
|
135
|
+
@server.tool(
|
|
136
|
+
annotations=ToolAnnotations(
|
|
137
|
+
title="List Kubeconfig Contexts",
|
|
138
|
+
readOnlyHint=True,
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
def list_kubeconfig_contexts() -> Dict[str, Any]:
|
|
142
|
+
"""List all contexts from kubeconfig with detailed info."""
|
|
143
|
+
try:
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
["kubectl", "config", "get-contexts", "-o", "name"],
|
|
146
|
+
capture_output=True, text=True, timeout=10
|
|
147
|
+
)
|
|
148
|
+
if result.returncode == 0:
|
|
149
|
+
contexts = [c.strip() for c in result.stdout.strip().split("\n") if c.strip()]
|
|
150
|
+
return {"success": True, "contexts": contexts}
|
|
151
|
+
return {"success": False, "error": result.stderr}
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Error listing kubeconfig contexts: {e}")
|
|
154
|
+
return {"success": False, "error": str(e)}
|
|
155
|
+
|
|
156
|
+
@server.tool(
|
|
157
|
+
annotations=ToolAnnotations(
|
|
158
|
+
title="Get Cluster Info",
|
|
159
|
+
readOnlyHint=True,
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
def get_cluster_info() -> Dict[str, Any]:
|
|
163
|
+
"""Get cluster information."""
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["kubectl", "cluster-info"],
|
|
167
|
+
capture_output=True, text=True, timeout=30
|
|
168
|
+
)
|
|
169
|
+
if result.returncode == 0:
|
|
170
|
+
return {"success": True, "info": result.stdout}
|
|
171
|
+
return {"success": False, "error": result.stderr}
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error(f"Error getting cluster info: {e}")
|
|
174
|
+
return {"success": False, "error": str(e)}
|
|
175
|
+
|
|
176
|
+
@server.tool(
|
|
177
|
+
annotations=ToolAnnotations(
|
|
178
|
+
title="Kubectl Explain",
|
|
179
|
+
readOnlyHint=True,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
def kubectl_explain(resource: str) -> Dict[str, Any]:
|
|
183
|
+
"""Explain a Kubernetes resource."""
|
|
184
|
+
try:
|
|
185
|
+
result = subprocess.run(
|
|
186
|
+
["kubectl", "explain", resource],
|
|
187
|
+
capture_output=True, text=True, timeout=30
|
|
188
|
+
)
|
|
189
|
+
return {"success": result.returncode == 0, "output": result.stdout or result.stderr}
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.error(f"Error explaining resource: {e}")
|
|
192
|
+
return {"success": False, "error": str(e)}
|
|
193
|
+
|
|
194
|
+
@server.tool(
|
|
195
|
+
annotations=ToolAnnotations(
|
|
196
|
+
title="Get API Resources",
|
|
197
|
+
readOnlyHint=True,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
def get_api_resources() -> Dict[str, Any]:
|
|
201
|
+
"""Get available API resources."""
|
|
202
|
+
try:
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["kubectl", "api-resources"],
|
|
205
|
+
capture_output=True, text=True, timeout=30
|
|
206
|
+
)
|
|
207
|
+
return {"success": result.returncode == 0, "output": result.stdout or result.stderr}
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Error getting API resources: {e}")
|
|
210
|
+
return {"success": False, "error": str(e)}
|
|
211
|
+
|
|
212
|
+
@server.tool(
|
|
213
|
+
annotations=ToolAnnotations(
|
|
214
|
+
title="Health Check",
|
|
215
|
+
readOnlyHint=True,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
def health_check() -> Dict[str, Any]:
|
|
219
|
+
"""Perform a cluster health check."""
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
["kubectl", "get", "componentstatuses", "-o", "json"],
|
|
223
|
+
capture_output=True, text=True, timeout=30
|
|
224
|
+
)
|
|
225
|
+
if result.returncode == 0:
|
|
226
|
+
import json
|
|
227
|
+
data = json.loads(result.stdout)
|
|
228
|
+
return {"success": True, "components": data.get("items", [])}
|
|
229
|
+
return {"success": False, "error": result.stderr}
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error(f"Error performing health check: {e}")
|
|
232
|
+
return {"success": False, "error": str(e)}
|
|
233
|
+
|
|
234
|
+
@server.tool(
|
|
235
|
+
annotations=ToolAnnotations(
|
|
236
|
+
title="Get Cluster Version Info",
|
|
237
|
+
readOnlyHint=True,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
def get_cluster_version() -> Dict[str, Any]:
|
|
241
|
+
"""Get Kubernetes cluster version information."""
|
|
242
|
+
try:
|
|
243
|
+
from kubernetes import client, config
|
|
244
|
+
config.load_kube_config()
|
|
245
|
+
version_api = client.VersionApi()
|
|
246
|
+
version_info = version_api.get_code()
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
"success": True,
|
|
250
|
+
"version": {
|
|
251
|
+
"gitVersion": version_info.git_version,
|
|
252
|
+
"major": version_info.major,
|
|
253
|
+
"minor": version_info.minor,
|
|
254
|
+
"platform": version_info.platform,
|
|
255
|
+
"buildDate": version_info.build_date,
|
|
256
|
+
"goVersion": version_info.go_version,
|
|
257
|
+
"compiler": version_info.compiler
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"Error getting cluster version: {e}")
|
|
262
|
+
return {"success": False, "error": str(e)}
|
|
263
|
+
|
|
264
|
+
@server.tool(
|
|
265
|
+
annotations=ToolAnnotations(
|
|
266
|
+
title="Get Admission Webhooks",
|
|
267
|
+
readOnlyHint=True,
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
def get_admission_webhooks() -> Dict[str, Any]:
|
|
271
|
+
"""Get admission webhooks configured in the cluster."""
|
|
272
|
+
try:
|
|
273
|
+
from kubernetes import client, config
|
|
274
|
+
config.load_kube_config()
|
|
275
|
+
api = client.AdmissionregistrationV1Api()
|
|
276
|
+
|
|
277
|
+
validating = api.list_validating_webhook_configuration()
|
|
278
|
+
mutating = api.list_mutating_webhook_configuration()
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"success": True,
|
|
282
|
+
"validatingWebhooks": [
|
|
283
|
+
{
|
|
284
|
+
"name": w.metadata.name,
|
|
285
|
+
"webhooks": [
|
|
286
|
+
{
|
|
287
|
+
"name": wh.name,
|
|
288
|
+
"failurePolicy": wh.failure_policy,
|
|
289
|
+
"matchPolicy": wh.match_policy,
|
|
290
|
+
"sideEffects": wh.side_effects
|
|
291
|
+
}
|
|
292
|
+
for wh in (w.webhooks or [])
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
for w in validating.items
|
|
296
|
+
],
|
|
297
|
+
"mutatingWebhooks": [
|
|
298
|
+
{
|
|
299
|
+
"name": w.metadata.name,
|
|
300
|
+
"webhooks": [
|
|
301
|
+
{
|
|
302
|
+
"name": wh.name,
|
|
303
|
+
"failurePolicy": wh.failure_policy,
|
|
304
|
+
"matchPolicy": wh.match_policy,
|
|
305
|
+
"sideEffects": wh.side_effects
|
|
306
|
+
}
|
|
307
|
+
for wh in (w.webhooks or [])
|
|
308
|
+
]
|
|
309
|
+
}
|
|
310
|
+
for w in mutating.items
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(f"Error getting admission webhooks: {e}")
|
|
315
|
+
return {"success": False, "error": str(e)}
|