kubectl-mcp-server 1.12.0__py3-none-any.whl → 1.13.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,1068 @@
1
+ """
2
+ MCP-UI enabled tools for kubectl-mcp-server.
3
+
4
+ These tools return UIResource objects that can be rendered by MCP hosts
5
+ that support the mcp-ui specification (Goose, LibreChat, Nanobot, etc.)
6
+
7
+ For hosts that don't support mcp-ui, these tools can optionally render
8
+ the UI to a screenshot using agent-browser, making them accessible to
9
+ ALL MCP hosts including Claude Desktop.
10
+
11
+ Installation: pip install mcp-ui-server
12
+ Browser screenshots: Requires agent-browser CLI (MCP_BROWSER_ENABLED=true)
13
+ """
14
+
15
+ import base64
16
+ import json
17
+ import logging
18
+ import html
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ import tempfile
23
+ from typing import Any, Dict, List, Optional, Union
24
+
25
+ from mcp.types import ToolAnnotations
26
+
27
+ logger = logging.getLogger("mcp-server")
28
+
29
+ # Check if agent-browser is available for screenshot rendering
30
+ BROWSER_ENABLED = os.environ.get("MCP_BROWSER_ENABLED", "").lower() in ("1", "true")
31
+ BROWSER_AVAILABLE = shutil.which("agent-browser") is not None
32
+
33
+ # Try to import mcp-ui-server, gracefully handle if not installed
34
+ try:
35
+ from mcp_ui_server import create_ui_resource, UIMetadataKey
36
+ from mcp_ui_server.core import UIResource
37
+ MCP_UI_AVAILABLE = True
38
+ except ImportError:
39
+ MCP_UI_AVAILABLE = False
40
+ UIResource = Dict[str, Any] # Fallback type
41
+ logger.warning("mcp-ui-server not installed. UI tools will return plain JSON.")
42
+
43
+
44
+ def _render_html_to_screenshot(html_content: str, width: int = 1200, height: int = 800) -> Optional[str]:
45
+ """Render HTML to a screenshot using agent-browser.
46
+
47
+ Returns base64-encoded PNG image or None if browser not available.
48
+ """
49
+ if not BROWSER_ENABLED or not BROWSER_AVAILABLE:
50
+ return None
51
+
52
+ try:
53
+ # Save HTML to temp file
54
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) as f:
55
+ f.write(html_content)
56
+ html_path = f.name
57
+
58
+ # Screenshot path
59
+ screenshot_path = tempfile.mktemp(suffix='.png')
60
+
61
+ try:
62
+ # Use agent-browser to render and screenshot
63
+ # Open the HTML file
64
+ subprocess.run(
65
+ ["agent-browser", "open", f"file://{html_path}"],
66
+ capture_output=True, timeout=10
67
+ )
68
+
69
+ # Set viewport size
70
+ subprocess.run(
71
+ ["agent-browser", "eval", f"window.resizeTo({width}, {height})"],
72
+ capture_output=True, timeout=5
73
+ )
74
+
75
+ # Wait for render
76
+ subprocess.run(
77
+ ["agent-browser", "wait", "500"],
78
+ capture_output=True, timeout=5
79
+ )
80
+
81
+ # Take screenshot
82
+ result = subprocess.run(
83
+ ["agent-browser", "screenshot", screenshot_path],
84
+ capture_output=True, timeout=10
85
+ )
86
+
87
+ if result.returncode == 0 and os.path.exists(screenshot_path):
88
+ with open(screenshot_path, 'rb') as f:
89
+ return base64.b64encode(f.read()).decode('utf-8')
90
+
91
+ return None
92
+ finally:
93
+ # Cleanup temp files
94
+ if os.path.exists(html_path):
95
+ os.unlink(html_path)
96
+ if os.path.exists(screenshot_path):
97
+ os.unlink(screenshot_path)
98
+ except Exception as e:
99
+ logger.warning(f"Failed to render screenshot: {e}")
100
+ return None
101
+
102
+
103
+ def _can_render_screenshots() -> bool:
104
+ """Check if screenshot rendering is available."""
105
+ return BROWSER_ENABLED and BROWSER_AVAILABLE
106
+
107
+
108
+ # CSS styles for consistent theming across UI components
109
+ DARK_THEME_CSS = """
110
+ :root {
111
+ --bg-primary: #1e1e2e;
112
+ --bg-secondary: #313244;
113
+ --bg-tertiary: #45475a;
114
+ --text-primary: #cdd6f4;
115
+ --text-secondary: #a6adc8;
116
+ --text-muted: #6c7086;
117
+ --accent-blue: #89b4fa;
118
+ --accent-green: #a6e3a1;
119
+ --accent-yellow: #f9e2af;
120
+ --accent-red: #f38ba8;
121
+ --accent-purple: #cba6f7;
122
+ --border-color: #45475a;
123
+ }
124
+ * { box-sizing: border-box; margin: 0; padding: 0; }
125
+ body {
126
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
127
+ background: var(--bg-primary);
128
+ color: var(--text-primary);
129
+ line-height: 1.5;
130
+ padding: 16px;
131
+ }
132
+ .container { max-width: 100%; }
133
+ .header {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 12px;
137
+ margin-bottom: 16px;
138
+ padding-bottom: 12px;
139
+ border-bottom: 1px solid var(--border-color);
140
+ }
141
+ .header h2 { font-size: 1.25rem; font-weight: 600; }
142
+ .badge {
143
+ display: inline-flex;
144
+ align-items: center;
145
+ padding: 2px 8px;
146
+ border-radius: 4px;
147
+ font-size: 0.75rem;
148
+ font-weight: 500;
149
+ }
150
+ .badge-green { background: rgba(166, 227, 161, 0.2); color: var(--accent-green); }
151
+ .badge-yellow { background: rgba(249, 226, 175, 0.2); color: var(--accent-yellow); }
152
+ .badge-red { background: rgba(243, 139, 168, 0.2); color: var(--accent-red); }
153
+ .badge-blue { background: rgba(137, 180, 250, 0.2); color: var(--accent-blue); }
154
+ .card {
155
+ background: var(--bg-secondary);
156
+ border-radius: 8px;
157
+ padding: 16px;
158
+ margin-bottom: 12px;
159
+ border: 1px solid var(--border-color);
160
+ }
161
+ .card-header {
162
+ display: flex;
163
+ justify-content: space-between;
164
+ align-items: center;
165
+ margin-bottom: 12px;
166
+ }
167
+ .card-title { font-weight: 600; font-size: 0.9rem; }
168
+ pre, .log-content {
169
+ background: var(--bg-tertiary);
170
+ border-radius: 6px;
171
+ padding: 12px;
172
+ overflow-x: auto;
173
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
174
+ font-size: 0.8rem;
175
+ line-height: 1.6;
176
+ white-space: pre-wrap;
177
+ word-break: break-word;
178
+ }
179
+ .log-line { display: block; }
180
+ .log-line:hover { background: rgba(137, 180, 250, 0.1); }
181
+ .log-error { color: var(--accent-red); }
182
+ .log-warn { color: var(--accent-yellow); }
183
+ .log-info { color: var(--accent-blue); }
184
+ table {
185
+ width: 100%;
186
+ border-collapse: collapse;
187
+ font-size: 0.85rem;
188
+ }
189
+ th, td {
190
+ text-align: left;
191
+ padding: 8px 12px;
192
+ border-bottom: 1px solid var(--border-color);
193
+ }
194
+ th {
195
+ background: var(--bg-tertiary);
196
+ font-weight: 600;
197
+ color: var(--text-secondary);
198
+ font-size: 0.75rem;
199
+ text-transform: uppercase;
200
+ }
201
+ tr:hover td { background: rgba(137, 180, 250, 0.05); }
202
+ .btn {
203
+ display: inline-flex;
204
+ align-items: center;
205
+ gap: 6px;
206
+ padding: 6px 12px;
207
+ border-radius: 6px;
208
+ border: none;
209
+ font-size: 0.8rem;
210
+ font-weight: 500;
211
+ cursor: pointer;
212
+ transition: all 0.2s;
213
+ }
214
+ .btn-primary { background: var(--accent-blue); color: var(--bg-primary); }
215
+ .btn-primary:hover { opacity: 0.9; }
216
+ .btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); }
217
+ .btn-secondary:hover { background: var(--border-color); }
218
+ .btn-danger { background: var(--accent-red); color: var(--bg-primary); }
219
+ .actions { display: flex; gap: 8px; margin-top: 12px; }
220
+ .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
221
+ .stat-card {
222
+ background: var(--bg-tertiary);
223
+ padding: 12px;
224
+ border-radius: 6px;
225
+ text-align: center;
226
+ }
227
+ .stat-value { font-size: 1.5rem; font-weight: 700; }
228
+ .stat-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
229
+ .search-box {
230
+ width: 100%;
231
+ padding: 8px 12px;
232
+ background: var(--bg-tertiary);
233
+ border: 1px solid var(--border-color);
234
+ border-radius: 6px;
235
+ color: var(--text-primary);
236
+ font-size: 0.85rem;
237
+ margin-bottom: 12px;
238
+ }
239
+ .search-box:focus { outline: none; border-color: var(--accent-blue); }
240
+ .timestamp { color: var(--text-muted); font-size: 0.75rem; }
241
+ .status-indicator {
242
+ display: inline-block;
243
+ width: 8px;
244
+ height: 8px;
245
+ border-radius: 50%;
246
+ margin-right: 6px;
247
+ }
248
+ .status-running { background: var(--accent-green); }
249
+ .status-pending { background: var(--accent-yellow); }
250
+ .status-failed { background: var(--accent-red); }
251
+ .status-unknown { background: var(--text-muted); }
252
+ """
253
+
254
+
255
+ def _escape(text: str) -> str:
256
+ """HTML escape text content."""
257
+ return html.escape(str(text)) if text else ""
258
+
259
+
260
+ def _create_ui_or_fallback(
261
+ uri: str,
262
+ html_content: str,
263
+ fallback_data: Dict[str, Any],
264
+ frame_size: tuple = ("800px", "500px"),
265
+ render_screenshot: bool = False
266
+ ) -> Union[List[UIResource], Dict[str, Any]]:
267
+ """Create UIResource if available, otherwise return fallback JSON.
268
+
269
+ Args:
270
+ uri: Unique URI for the resource (e.g., ui://cluster-overview)
271
+ html_content: The HTML content to render
272
+ fallback_data: JSON data to return if MCP-UI not available
273
+ frame_size: Preferred frame size (width, height) in CSS units
274
+ render_screenshot: If True and browser available, also render screenshot
275
+ """
276
+ # Option 1: Render screenshot using agent-browser (works everywhere)
277
+ if render_screenshot and _can_render_screenshots():
278
+ width = int(frame_size[0].replace('px', '').replace('%', '0'))
279
+ height = int(frame_size[1].replace('px', '').replace('%', '0'))
280
+ screenshot_b64 = _render_html_to_screenshot(html_content, width, height)
281
+ if screenshot_b64:
282
+ # Return as embedded image that any MCP host can display
283
+ return {
284
+ "success": True,
285
+ "image": {
286
+ "type": "base64",
287
+ "media_type": "image/png",
288
+ "data": screenshot_b64
289
+ },
290
+ "note": "Screenshot rendered via agent-browser. For interactive UI, use a host that supports MCP-UI.",
291
+ **fallback_data
292
+ }
293
+
294
+ # Option 2: Return MCP-UI resource (for compatible hosts)
295
+ if MCP_UI_AVAILABLE:
296
+ try:
297
+ ui_resource = create_ui_resource({
298
+ "uri": uri,
299
+ "content": {
300
+ "type": "rawHtml",
301
+ "htmlString": html_content
302
+ },
303
+ "encoding": "text",
304
+ "uiMetadata": {
305
+ UIMetadataKey.PREFERRED_FRAME_SIZE: list(frame_size)
306
+ }
307
+ })
308
+ return [ui_resource]
309
+ except Exception as e:
310
+ logger.warning(f"Failed to create UI resource: {e}")
311
+
312
+ # Option 3: Return plain JSON fallback
313
+ return fallback_data
314
+
315
+
316
+ def register_ui_tools(server, non_destructive: bool):
317
+ """Register UI-enhanced tools with the MCP server.
318
+
319
+ Args:
320
+ server: FastMCP server instance
321
+ non_destructive: If True, block destructive operations
322
+ """
323
+
324
+ @server.tool(
325
+ annotations=ToolAnnotations(
326
+ title="Show Pod Logs UI",
327
+ readOnlyHint=True,
328
+ ),
329
+ )
330
+ def show_pod_logs_ui(
331
+ pod_name: str,
332
+ namespace: str = "default",
333
+ container: Optional[str] = None,
334
+ tail: int = 100
335
+ ) -> Union[List[UIResource], Dict[str, Any]]:
336
+ """Display pod logs in an interactive UI with search and syntax highlighting."""
337
+ try:
338
+ from kubernetes import client, config
339
+ config.load_kube_config()
340
+ v1 = client.CoreV1Api()
341
+
342
+ logs = v1.read_namespaced_pod_log(
343
+ name=pod_name,
344
+ namespace=namespace,
345
+ container=container,
346
+ tail_lines=tail
347
+ )
348
+
349
+ # Process log lines for highlighting
350
+ log_lines = logs.split('\n') if logs else []
351
+ processed_lines = []
352
+ for line in log_lines:
353
+ escaped = _escape(line)
354
+ css_class = "log-line"
355
+ if any(kw in line.lower() for kw in ['error', 'fail', 'exception', 'panic']):
356
+ css_class += " log-error"
357
+ elif any(kw in line.lower() for kw in ['warn', 'warning']):
358
+ css_class += " log-warn"
359
+ elif any(kw in line.lower() for kw in ['info']):
360
+ css_class += " log-info"
361
+ processed_lines.append(f'<span class="{css_class}">{escaped}</span>')
362
+
363
+ html_content = f"""<!DOCTYPE html>
364
+ <html><head><style>{DARK_THEME_CSS}</style></head>
365
+ <body>
366
+ <div class="container">
367
+ <div class="header">
368
+ <h2>Pod Logs</h2>
369
+ <span class="badge badge-blue">{_escape(namespace)}/{_escape(pod_name)}</span>
370
+ {f'<span class="badge badge-green">{_escape(container)}</span>' if container else ''}
371
+ </div>
372
+ <input type="text" class="search-box" placeholder="Search logs..." id="search" oninput="filterLogs()">
373
+ <div class="card">
374
+ <div class="card-header">
375
+ <span class="card-title">Last {len(log_lines)} lines</span>
376
+ <div class="actions">
377
+ <button class="btn btn-secondary" onclick="copyLogs()">Copy</button>
378
+ <button class="btn btn-primary" onclick="refreshLogs()">Refresh</button>
379
+ </div>
380
+ </div>
381
+ <pre class="log-content" id="logs">{''.join(processed_lines)}</pre>
382
+ </div>
383
+ </div>
384
+ <script>
385
+ function filterLogs() {{
386
+ const search = document.getElementById('search').value.toLowerCase();
387
+ const lines = document.querySelectorAll('.log-line');
388
+ lines.forEach(line => {{
389
+ line.style.display = line.textContent.toLowerCase().includes(search) ? 'block' : 'none';
390
+ }});
391
+ }}
392
+ function copyLogs() {{
393
+ const logs = document.getElementById('logs').textContent;
394
+ navigator.clipboard.writeText(logs);
395
+ }}
396
+ function refreshLogs() {{
397
+ window.parent.postMessage({{
398
+ type: 'tool',
399
+ payload: {{
400
+ toolName: 'show_pod_logs_ui',
401
+ params: {{ pod_name: '{_escape(pod_name)}', namespace: '{_escape(namespace)}', tail: {tail} }}
402
+ }}
403
+ }}, '*');
404
+ }}
405
+ </script>
406
+ </body></html>"""
407
+
408
+ return _create_ui_or_fallback(
409
+ uri=f"ui://pod-logs/{namespace}/{pod_name}",
410
+ html_content=html_content,
411
+ fallback_data={"success": True, "logs": logs, "lineCount": len(log_lines)},
412
+ frame_size=("900px", "600px")
413
+ )
414
+
415
+ except Exception as e:
416
+ logger.error(f"Error showing pod logs UI: {e}")
417
+ return {"success": False, "error": str(e)}
418
+
419
+ @server.tool(
420
+ annotations=ToolAnnotations(
421
+ title="Show Pods Dashboard UI",
422
+ readOnlyHint=True,
423
+ ),
424
+ )
425
+ def show_pods_dashboard_ui(
426
+ namespace: Optional[str] = None
427
+ ) -> Union[List[UIResource], Dict[str, Any]]:
428
+ """Display an interactive dashboard showing all pods with status, metrics, and actions."""
429
+ try:
430
+ from kubernetes import client, config
431
+ config.load_kube_config()
432
+ v1 = client.CoreV1Api()
433
+
434
+ if namespace:
435
+ pods = v1.list_namespaced_pod(namespace)
436
+ else:
437
+ pods = v1.list_pod_for_all_namespaces()
438
+
439
+ # Count by status
440
+ status_counts = {"Running": 0, "Pending": 0, "Failed": 0, "Succeeded": 0, "Unknown": 0}
441
+ pod_rows = []
442
+
443
+ for pod in pods.items:
444
+ phase = pod.status.phase or "Unknown"
445
+ status_counts[phase] = status_counts.get(phase, 0) + 1
446
+
447
+ # Determine status indicator class
448
+ status_class = {
449
+ "Running": "status-running",
450
+ "Pending": "status-pending",
451
+ "Failed": "status-failed",
452
+ "Succeeded": "status-running"
453
+ }.get(phase, "status-unknown")
454
+
455
+ # Get restart count
456
+ restart_count = 0
457
+ if pod.status.container_statuses:
458
+ restart_count = sum(cs.restart_count for cs in pod.status.container_statuses)
459
+
460
+ pod_rows.append(f"""
461
+ <tr>
462
+ <td><span class="status-indicator {status_class}"></span>{_escape(pod.metadata.name)}</td>
463
+ <td>{_escape(pod.metadata.namespace)}</td>
464
+ <td><span class="badge badge-{'green' if phase == 'Running' else 'yellow' if phase == 'Pending' else 'red'}">{_escape(phase)}</span></td>
465
+ <td>{restart_count}</td>
466
+ <td>{_escape(pod.status.pod_ip or '-')}</td>
467
+ <td>
468
+ <button class="btn btn-secondary" onclick="viewLogs('{_escape(pod.metadata.name)}', '{_escape(pod.metadata.namespace)}')">Logs</button>
469
+ </td>
470
+ </tr>""")
471
+
472
+ total_pods = len(pods.items)
473
+ ns_display = f"Namespace: {_escape(namespace)}" if namespace else "All Namespaces"
474
+
475
+ html_content = f"""<!DOCTYPE html>
476
+ <html><head><style>{DARK_THEME_CSS}</style></head>
477
+ <body>
478
+ <div class="container">
479
+ <div class="header">
480
+ <h2>Pods Dashboard</h2>
481
+ <span class="badge badge-blue">{ns_display}</span>
482
+ </div>
483
+
484
+ <div class="stat-grid">
485
+ <div class="stat-card">
486
+ <div class="stat-value">{total_pods}</div>
487
+ <div class="stat-label">Total Pods</div>
488
+ </div>
489
+ <div class="stat-card">
490
+ <div class="stat-value" style="color: var(--accent-green)">{status_counts.get('Running', 0)}</div>
491
+ <div class="stat-label">Running</div>
492
+ </div>
493
+ <div class="stat-card">
494
+ <div class="stat-value" style="color: var(--accent-yellow)">{status_counts.get('Pending', 0)}</div>
495
+ <div class="stat-label">Pending</div>
496
+ </div>
497
+ <div class="stat-card">
498
+ <div class="stat-value" style="color: var(--accent-red)">{status_counts.get('Failed', 0)}</div>
499
+ <div class="stat-label">Failed</div>
500
+ </div>
501
+ </div>
502
+
503
+ <div class="card" style="margin-top: 16px">
504
+ <input type="text" class="search-box" placeholder="Filter pods..." oninput="filterPods(this.value)">
505
+ <div style="overflow-x: auto;">
506
+ <table id="pods-table">
507
+ <thead>
508
+ <tr>
509
+ <th>Name</th>
510
+ <th>Namespace</th>
511
+ <th>Status</th>
512
+ <th>Restarts</th>
513
+ <th>IP</th>
514
+ <th>Actions</th>
515
+ </tr>
516
+ </thead>
517
+ <tbody>{''.join(pod_rows)}</tbody>
518
+ </table>
519
+ </div>
520
+ </div>
521
+ </div>
522
+ <script>
523
+ function filterPods(search) {{
524
+ const rows = document.querySelectorAll('#pods-table tbody tr');
525
+ search = search.toLowerCase();
526
+ rows.forEach(row => {{
527
+ row.style.display = row.textContent.toLowerCase().includes(search) ? '' : 'none';
528
+ }});
529
+ }}
530
+ function viewLogs(pod, ns) {{
531
+ window.parent.postMessage({{
532
+ type: 'tool',
533
+ payload: {{ toolName: 'show_pod_logs_ui', params: {{ pod_name: pod, namespace: ns, tail: 100 }} }}
534
+ }}, '*');
535
+ }}
536
+ </script>
537
+ </body></html>"""
538
+
539
+ return _create_ui_or_fallback(
540
+ uri=f"ui://pods-dashboard/{namespace or 'all'}",
541
+ html_content=html_content,
542
+ fallback_data={
543
+ "success": True,
544
+ "totalPods": total_pods,
545
+ "statusCounts": status_counts,
546
+ "pods": [
547
+ {"name": p.metadata.name, "namespace": p.metadata.namespace, "status": p.status.phase}
548
+ for p in pods.items[:50] # Limit for JSON response
549
+ ]
550
+ },
551
+ frame_size=("1000px", "700px")
552
+ )
553
+
554
+ except Exception as e:
555
+ logger.error(f"Error showing pods dashboard UI: {e}")
556
+ return {"success": False, "error": str(e)}
557
+
558
+ @server.tool(
559
+ annotations=ToolAnnotations(
560
+ title="Show Resource YAML UI",
561
+ readOnlyHint=True,
562
+ ),
563
+ )
564
+ def show_resource_yaml_ui(
565
+ resource_type: str,
566
+ name: str,
567
+ namespace: str = "default"
568
+ ) -> Union[List[UIResource], Dict[str, Any]]:
569
+ """Display Kubernetes resource YAML with syntax highlighting and actions."""
570
+ try:
571
+ import subprocess
572
+ import yaml
573
+
574
+ cmd = ["kubectl", "get", resource_type, name, "-n", namespace, "-o", "yaml"]
575
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
576
+
577
+ if result.returncode != 0:
578
+ return {"success": False, "error": result.stderr.strip()}
579
+
580
+ yaml_content = result.stdout
581
+
582
+ # Basic YAML syntax highlighting
583
+ highlighted_lines = []
584
+ for line in yaml_content.split('\n'):
585
+ escaped = _escape(line)
586
+ # Highlight keys
587
+ if ':' in line and not line.strip().startswith('-'):
588
+ key_part = escaped.split(':')[0]
589
+ rest = ':'.join(escaped.split(':')[1:])
590
+ escaped = f'<span style="color: var(--accent-blue)">{key_part}</span>:{rest}'
591
+ # Highlight comments
592
+ if line.strip().startswith('#'):
593
+ escaped = f'<span style="color: var(--text-muted)">{escaped}</span>'
594
+ highlighted_lines.append(escaped)
595
+
596
+ # Escape YAML for JavaScript template literal (must be done outside f-string)
597
+ escaped_yaml = yaml_content.replace('`', r'\`').replace('$', r'\$')
598
+
599
+ html_content = f"""<!DOCTYPE html>
600
+ <html><head><style>{DARK_THEME_CSS}</style></head>
601
+ <body>
602
+ <div class="container">
603
+ <div class="header">
604
+ <h2>{_escape(resource_type)}: {_escape(name)}</h2>
605
+ <span class="badge badge-blue">{_escape(namespace)}</span>
606
+ </div>
607
+ <div class="card">
608
+ <div class="card-header">
609
+ <span class="card-title">YAML Definition</span>
610
+ <div class="actions">
611
+ <button class="btn btn-secondary" onclick="copyYaml()">Copy YAML</button>
612
+ </div>
613
+ </div>
614
+ <pre id="yaml-content">{chr(10).join(highlighted_lines)}</pre>
615
+ </div>
616
+ </div>
617
+ <script>
618
+ function copyYaml() {{
619
+ const yaml = `{escaped_yaml}`;
620
+ navigator.clipboard.writeText(yaml);
621
+ }}
622
+ </script>
623
+ </body></html>"""
624
+
625
+ return _create_ui_or_fallback(
626
+ uri=f"ui://resource-yaml/{resource_type}/{namespace}/{name}",
627
+ html_content=html_content,
628
+ fallback_data={"success": True, "yaml": yaml_content},
629
+ frame_size=("900px", "700px")
630
+ )
631
+
632
+ except Exception as e:
633
+ logger.error(f"Error showing resource YAML UI: {e}")
634
+ return {"success": False, "error": str(e)}
635
+
636
+ @server.tool(
637
+ annotations=ToolAnnotations(
638
+ title="Show Cluster Overview UI",
639
+ readOnlyHint=True,
640
+ ),
641
+ )
642
+ def show_cluster_overview_ui() -> Union[List[UIResource], Dict[str, Any]]:
643
+ """Display a comprehensive cluster overview dashboard with nodes, namespaces, and key metrics."""
644
+ try:
645
+ from kubernetes import client, config
646
+ config.load_kube_config()
647
+ v1 = client.CoreV1Api()
648
+
649
+ # Get nodes
650
+ nodes = v1.list_node()
651
+ node_rows = []
652
+ ready_nodes = 0
653
+
654
+ for node in nodes.items:
655
+ is_ready = False
656
+ for condition in (node.status.conditions or []):
657
+ if condition.type == "Ready" and condition.status == "True":
658
+ is_ready = True
659
+ ready_nodes += 1
660
+ break
661
+
662
+ # Get allocatable resources
663
+ allocatable = node.status.allocatable or {}
664
+ cpu = allocatable.get("cpu", "N/A")
665
+ memory = allocatable.get("memory", "N/A")
666
+
667
+ node_rows.append(f"""
668
+ <tr>
669
+ <td><span class="status-indicator {'status-running' if is_ready else 'status-failed'}"></span>{_escape(node.metadata.name)}</td>
670
+ <td><span class="badge badge-{'green' if is_ready else 'red'}">{'Ready' if is_ready else 'NotReady'}</span></td>
671
+ <td>{_escape(cpu)}</td>
672
+ <td>{_escape(memory)}</td>
673
+ </tr>""")
674
+
675
+ # Get namespaces
676
+ namespaces = v1.list_namespace()
677
+ ns_count = len(namespaces.items)
678
+
679
+ # Get pods summary
680
+ pods = v1.list_pod_for_all_namespaces()
681
+ total_pods = len(pods.items)
682
+ running_pods = sum(1 for p in pods.items if p.status.phase == "Running")
683
+
684
+ # Get services
685
+ services = v1.list_service_for_all_namespaces()
686
+ svc_count = len(services.items)
687
+
688
+ html_content = f"""<!DOCTYPE html>
689
+ <html><head><style>{DARK_THEME_CSS}</style></head>
690
+ <body>
691
+ <div class="container">
692
+ <div class="header">
693
+ <h2>Cluster Overview</h2>
694
+ <span class="badge badge-green">Connected</span>
695
+ </div>
696
+
697
+ <div class="stat-grid">
698
+ <div class="stat-card">
699
+ <div class="stat-value">{len(nodes.items)}</div>
700
+ <div class="stat-label">Nodes ({ready_nodes} Ready)</div>
701
+ </div>
702
+ <div class="stat-card">
703
+ <div class="stat-value">{ns_count}</div>
704
+ <div class="stat-label">Namespaces</div>
705
+ </div>
706
+ <div class="stat-card">
707
+ <div class="stat-value" style="color: var(--accent-green)">{running_pods}/{total_pods}</div>
708
+ <div class="stat-label">Pods Running</div>
709
+ </div>
710
+ <div class="stat-card">
711
+ <div class="stat-value">{svc_count}</div>
712
+ <div class="stat-label">Services</div>
713
+ </div>
714
+ </div>
715
+
716
+ <div class="card" style="margin-top: 16px">
717
+ <div class="card-header">
718
+ <span class="card-title">Nodes</span>
719
+ </div>
720
+ <table>
721
+ <thead>
722
+ <tr><th>Name</th><th>Status</th><th>CPU</th><th>Memory</th></tr>
723
+ </thead>
724
+ <tbody>{''.join(node_rows)}</tbody>
725
+ </table>
726
+ </div>
727
+
728
+ <div class="actions">
729
+ <button class="btn btn-primary" onclick="viewPods()">View All Pods</button>
730
+ <button class="btn btn-secondary" onclick="refresh()">Refresh</button>
731
+ </div>
732
+ </div>
733
+ <script>
734
+ function viewPods() {{
735
+ window.parent.postMessage({{
736
+ type: 'tool',
737
+ payload: {{ toolName: 'show_pods_dashboard_ui', params: {{}} }}
738
+ }}, '*');
739
+ }}
740
+ function refresh() {{
741
+ window.parent.postMessage({{
742
+ type: 'tool',
743
+ payload: {{ toolName: 'show_cluster_overview_ui', params: {{}} }}
744
+ }}, '*');
745
+ }}
746
+ </script>
747
+ </body></html>"""
748
+
749
+ return _create_ui_or_fallback(
750
+ uri="ui://cluster-overview",
751
+ html_content=html_content,
752
+ fallback_data={
753
+ "success": True,
754
+ "nodes": {"total": len(nodes.items), "ready": ready_nodes},
755
+ "namespaces": ns_count,
756
+ "pods": {"total": total_pods, "running": running_pods},
757
+ "services": svc_count
758
+ },
759
+ frame_size=("1000px", "700px")
760
+ )
761
+
762
+ except Exception as e:
763
+ logger.error(f"Error showing cluster overview UI: {e}")
764
+ return {"success": False, "error": str(e)}
765
+
766
+ @server.tool(
767
+ annotations=ToolAnnotations(
768
+ title="Show Events Timeline UI",
769
+ readOnlyHint=True,
770
+ ),
771
+ )
772
+ def show_events_timeline_ui(
773
+ namespace: Optional[str] = None,
774
+ limit: int = 50
775
+ ) -> Union[List[UIResource], Dict[str, Any]]:
776
+ """Display Kubernetes events in a timeline view with filtering by type."""
777
+ try:
778
+ from kubernetes import client, config
779
+ config.load_kube_config()
780
+ v1 = client.CoreV1Api()
781
+
782
+ if namespace:
783
+ events = v1.list_namespaced_event(namespace)
784
+ else:
785
+ events = v1.list_event_for_all_namespaces()
786
+
787
+ # Sort by timestamp (most recent first)
788
+ sorted_events = sorted(
789
+ events.items,
790
+ key=lambda e: e.last_timestamp or e.metadata.creation_timestamp or "",
791
+ reverse=True
792
+ )[:limit]
793
+
794
+ event_items = []
795
+ warning_count = 0
796
+ normal_count = 0
797
+
798
+ for event in sorted_events:
799
+ is_warning = event.type == "Warning"
800
+ if is_warning:
801
+ warning_count += 1
802
+ else:
803
+ normal_count += 1
804
+
805
+ timestamp = event.last_timestamp or event.metadata.creation_timestamp
806
+ time_str = timestamp.strftime("%H:%M:%S") if timestamp else "Unknown"
807
+
808
+ event_items.append(f"""
809
+ <div class="card" style="border-left: 3px solid var({'--accent-red' if is_warning else '--accent-green'})">
810
+ <div class="card-header">
811
+ <span class="card-title">
812
+ <span class="badge badge-{'red' if is_warning else 'green'}">{_escape(event.type)}</span>
813
+ {_escape(event.reason)}
814
+ </span>
815
+ <span class="timestamp">{time_str}</span>
816
+ </div>
817
+ <p style="font-size: 0.85rem; color: var(--text-secondary)">
818
+ {_escape(event.involved_object.kind)}: {_escape(event.involved_object.name)}
819
+ <span style="color: var(--text-muted)">in {_escape(event.metadata.namespace)}</span>
820
+ </p>
821
+ <p style="margin-top: 8px; font-size: 0.85rem">{_escape(event.message)}</p>
822
+ </div>""")
823
+
824
+ ns_display = f"Namespace: {_escape(namespace)}" if namespace else "All Namespaces"
825
+
826
+ html_content = f"""<!DOCTYPE html>
827
+ <html><head><style>{DARK_THEME_CSS}</style></head>
828
+ <body>
829
+ <div class="container">
830
+ <div class="header">
831
+ <h2>Events Timeline</h2>
832
+ <span class="badge badge-blue">{ns_display}</span>
833
+ </div>
834
+
835
+ <div class="stat-grid" style="margin-bottom: 16px">
836
+ <div class="stat-card">
837
+ <div class="stat-value">{len(sorted_events)}</div>
838
+ <div class="stat-label">Total Events</div>
839
+ </div>
840
+ <div class="stat-card">
841
+ <div class="stat-value" style="color: var(--accent-red)">{warning_count}</div>
842
+ <div class="stat-label">Warnings</div>
843
+ </div>
844
+ <div class="stat-card">
845
+ <div class="stat-value" style="color: var(--accent-green)">{normal_count}</div>
846
+ <div class="stat-label">Normal</div>
847
+ </div>
848
+ </div>
849
+
850
+ <div style="display: flex; gap: 8px; margin-bottom: 16px">
851
+ <button class="btn btn-secondary" onclick="filterEvents('all')">All</button>
852
+ <button class="btn btn-secondary" onclick="filterEvents('Warning')">Warnings</button>
853
+ <button class="btn btn-secondary" onclick="filterEvents('Normal')">Normal</button>
854
+ </div>
855
+
856
+ <div id="events">{''.join(event_items)}</div>
857
+ </div>
858
+ <script>
859
+ function filterEvents(type) {{
860
+ const events = document.querySelectorAll('#events .card');
861
+ events.forEach(e => {{
862
+ if (type === 'all') e.style.display = 'block';
863
+ else e.style.display = e.innerHTML.includes('>' + type + '<') ? 'block' : 'none';
864
+ }});
865
+ }}
866
+ </script>
867
+ </body></html>"""
868
+
869
+ return _create_ui_or_fallback(
870
+ uri=f"ui://events-timeline/{namespace or 'all'}",
871
+ html_content=html_content,
872
+ fallback_data={
873
+ "success": True,
874
+ "totalEvents": len(sorted_events),
875
+ "warnings": warning_count,
876
+ "normal": normal_count,
877
+ "events": [
878
+ {
879
+ "type": e.type,
880
+ "reason": e.reason,
881
+ "message": e.message,
882
+ "object": f"{e.involved_object.kind}/{e.involved_object.name}"
883
+ }
884
+ for e in sorted_events[:20]
885
+ ]
886
+ },
887
+ frame_size=("900px", "700px")
888
+ )
889
+
890
+ except Exception as e:
891
+ logger.error(f"Error showing events timeline UI: {e}")
892
+ return {"success": False, "error": str(e)}
893
+
894
+ @server.tool(
895
+ annotations=ToolAnnotations(
896
+ title="Render K8s Dashboard Screenshot",
897
+ readOnlyHint=True,
898
+ ),
899
+ )
900
+ def render_k8s_dashboard_screenshot(
901
+ dashboard: str = "cluster",
902
+ namespace: Optional[str] = None,
903
+ pod_name: Optional[str] = None,
904
+ resource_type: Optional[str] = None,
905
+ resource_name: Optional[str] = None
906
+ ) -> Dict[str, Any]:
907
+ """Render a Kubernetes dashboard to a screenshot image (works in all MCP hosts).
908
+
909
+ This uses agent-browser to render the UI and capture a screenshot,
910
+ making visual dashboards accessible even in hosts that don't support MCP-UI.
911
+
912
+ Args:
913
+ dashboard: Type of dashboard to render:
914
+ - "cluster": Cluster overview with nodes, pods, services
915
+ - "pods": Pods dashboard with status table
916
+ - "events": Events timeline
917
+ - "logs": Pod logs viewer (requires pod_name)
918
+ - "yaml": Resource YAML viewer (requires resource_type and resource_name)
919
+ namespace: Namespace filter (optional)
920
+ pod_name: Pod name for logs dashboard
921
+ resource_type: Resource type for YAML view (e.g., "deployment", "service")
922
+ resource_name: Resource name for YAML view
923
+ """
924
+ if not _can_render_screenshots():
925
+ return {
926
+ "success": False,
927
+ "error": "Screenshot rendering requires MCP_BROWSER_ENABLED=true and agent-browser installed"
928
+ }
929
+
930
+ try:
931
+ from kubernetes import client, config
932
+ config.load_kube_config()
933
+
934
+ # Generate the appropriate dashboard HTML
935
+ if dashboard == "cluster":
936
+ # Reuse cluster overview logic
937
+ v1 = client.CoreV1Api()
938
+ nodes = v1.list_node()
939
+ namespaces = v1.list_namespace()
940
+ pods = v1.list_pod_for_all_namespaces()
941
+ services = v1.list_service_for_all_namespaces()
942
+
943
+ ready_nodes = sum(1 for n in nodes.items
944
+ for c in (n.status.conditions or [])
945
+ if c.type == "Ready" and c.status == "True")
946
+ running_pods = sum(1 for p in pods.items if p.status.phase == "Running")
947
+
948
+ node_rows = []
949
+ for node in nodes.items:
950
+ is_ready = any(c.type == "Ready" and c.status == "True"
951
+ for c in (node.status.conditions or []))
952
+ allocatable = node.status.allocatable or {}
953
+ node_rows.append(f"""
954
+ <tr>
955
+ <td><span class="status-indicator {'status-running' if is_ready else 'status-failed'}"></span>{_escape(node.metadata.name)}</td>
956
+ <td><span class="badge badge-{'green' if is_ready else 'red'}">{'Ready' if is_ready else 'NotReady'}</span></td>
957
+ <td>{_escape(allocatable.get('cpu', 'N/A'))}</td>
958
+ <td>{_escape(allocatable.get('memory', 'N/A'))}</td>
959
+ </tr>""")
960
+
961
+ html_content = f"""<!DOCTYPE html>
962
+ <html><head><style>{DARK_THEME_CSS}</style></head>
963
+ <body>
964
+ <div class="container">
965
+ <div class="header">
966
+ <h2>Cluster Overview</h2>
967
+ <span class="badge badge-green">Connected</span>
968
+ </div>
969
+ <div class="stat-grid">
970
+ <div class="stat-card"><div class="stat-value">{len(nodes.items)}</div><div class="stat-label">Nodes ({ready_nodes} Ready)</div></div>
971
+ <div class="stat-card"><div class="stat-value">{len(namespaces.items)}</div><div class="stat-label">Namespaces</div></div>
972
+ <div class="stat-card"><div class="stat-value" style="color: var(--accent-green)">{running_pods}/{len(pods.items)}</div><div class="stat-label">Pods Running</div></div>
973
+ <div class="stat-card"><div class="stat-value">{len(services.items)}</div><div class="stat-label">Services</div></div>
974
+ </div>
975
+ <div class="card" style="margin-top: 16px">
976
+ <div class="card-header"><span class="card-title">Nodes</span></div>
977
+ <table><thead><tr><th>Name</th><th>Status</th><th>CPU</th><th>Memory</th></tr></thead>
978
+ <tbody>{''.join(node_rows)}</tbody></table>
979
+ </div>
980
+ </div>
981
+ </body></html>"""
982
+
983
+ elif dashboard == "pods":
984
+ v1 = client.CoreV1Api()
985
+ if namespace:
986
+ pods = v1.list_namespaced_pod(namespace)
987
+ else:
988
+ pods = v1.list_pod_for_all_namespaces()
989
+
990
+ pod_rows = []
991
+ for pod in pods.items[:30]: # Limit for screenshot
992
+ phase = pod.status.phase or "Unknown"
993
+ status_class = {"Running": "status-running", "Pending": "status-pending"}.get(phase, "status-failed")
994
+ badge_color = {"Running": "green", "Pending": "yellow"}.get(phase, "red")
995
+ pod_rows.append(f"""
996
+ <tr>
997
+ <td><span class="status-indicator {status_class}"></span>{_escape(pod.metadata.name[:40])}</td>
998
+ <td>{_escape(pod.metadata.namespace)}</td>
999
+ <td><span class="badge badge-{badge_color}">{_escape(phase)}</span></td>
1000
+ </tr>""")
1001
+
1002
+ html_content = f"""<!DOCTYPE html>
1003
+ <html><head><style>{DARK_THEME_CSS}</style></head>
1004
+ <body>
1005
+ <div class="container">
1006
+ <div class="header">
1007
+ <h2>Pods Dashboard</h2>
1008
+ <span class="badge badge-blue">{namespace or 'All Namespaces'}</span>
1009
+ </div>
1010
+ <div class="card">
1011
+ <table><thead><tr><th>Name</th><th>Namespace</th><th>Status</th></tr></thead>
1012
+ <tbody>{''.join(pod_rows)}</tbody></table>
1013
+ </div>
1014
+ </div>
1015
+ </body></html>"""
1016
+
1017
+ elif dashboard == "logs" and pod_name:
1018
+ v1 = client.CoreV1Api()
1019
+ ns = namespace or "default"
1020
+ logs = v1.read_namespaced_pod_log(name=pod_name, namespace=ns, tail_lines=50)
1021
+ log_lines = logs.split('\n') if logs else []
1022
+ processed = [f'<span class="log-line">{_escape(line)}</span>' for line in log_lines]
1023
+
1024
+ html_content = f"""<!DOCTYPE html>
1025
+ <html><head><style>{DARK_THEME_CSS}</style></head>
1026
+ <body>
1027
+ <div class="container">
1028
+ <div class="header">
1029
+ <h2>Pod Logs</h2>
1030
+ <span class="badge badge-blue">{_escape(ns)}/{_escape(pod_name)}</span>
1031
+ </div>
1032
+ <div class="card">
1033
+ <pre class="log-content">{''.join(processed)}</pre>
1034
+ </div>
1035
+ </div>
1036
+ </body></html>"""
1037
+
1038
+ else:
1039
+ return {"success": False, "error": f"Unknown dashboard type: {dashboard}"}
1040
+
1041
+ # Render to screenshot
1042
+ screenshot_b64 = _render_html_to_screenshot(html_content, 1200, 800)
1043
+ if screenshot_b64:
1044
+ return {
1045
+ "success": True,
1046
+ "dashboard": dashboard,
1047
+ "image": {
1048
+ "type": "base64",
1049
+ "media_type": "image/png",
1050
+ "data": screenshot_b64
1051
+ }
1052
+ }
1053
+ else:
1054
+ return {"success": False, "error": "Failed to render screenshot"}
1055
+
1056
+ except Exception as e:
1057
+ logger.error(f"Error rendering dashboard screenshot: {e}")
1058
+ return {"success": False, "error": str(e)}
1059
+
1060
+
1061
+ def is_ui_available() -> bool:
1062
+ """Check if MCP-UI is available."""
1063
+ return MCP_UI_AVAILABLE
1064
+
1065
+
1066
+ def is_screenshot_available() -> bool:
1067
+ """Check if screenshot rendering is available."""
1068
+ return _can_render_screenshots()