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.
@@ -5,7 +5,9 @@ import logging
5
5
  import os
6
6
  import shutil
7
7
  import subprocess
8
- from typing import Any, Dict, Optional
8
+ import tempfile
9
+ import time
10
+ from typing import Any, Dict, List, Optional
9
11
 
10
12
  from mcp.types import ToolAnnotations
11
13
 
@@ -14,9 +16,34 @@ logger = logging.getLogger("mcp-server")
14
16
  BROWSER_ENABLED = os.environ.get("MCP_BROWSER_ENABLED", "").lower() in ("1", "true")
15
17
  BROWSER_AVAILABLE = shutil.which("agent-browser") is not None
16
18
 
19
+ MCP_BROWSER_PROVIDER = os.environ.get("MCP_BROWSER_PROVIDER")
20
+ MCP_BROWSER_PROFILE = os.environ.get("MCP_BROWSER_PROFILE")
21
+ MCP_BROWSER_CDP_URL = os.environ.get("MCP_BROWSER_CDP_URL")
22
+ MCP_BROWSER_PROXY = os.environ.get("MCP_BROWSER_PROXY")
23
+ MCP_BROWSER_PROXY_BYPASS = os.environ.get("MCP_BROWSER_PROXY_BYPASS")
24
+ MCP_BROWSER_USER_AGENT = os.environ.get("MCP_BROWSER_USER_AGENT")
25
+ MCP_BROWSER_ARGS = os.environ.get("MCP_BROWSER_ARGS")
26
+ MCP_BROWSER_SESSION = os.environ.get("MCP_BROWSER_SESSION")
27
+ MCP_BROWSER_HEADED = os.environ.get("MCP_BROWSER_HEADED", "").lower() in ("1", "true")
28
+
29
+ MCP_BROWSER_DEBUG = os.environ.get("MCP_BROWSER_DEBUG", "").lower() in ("1", "true")
30
+ MCP_BROWSER_MAX_RETRIES = int(os.environ.get("MCP_BROWSER_MAX_RETRIES", "3"))
31
+ MCP_BROWSER_RETRY_DELAY = int(os.environ.get("MCP_BROWSER_RETRY_DELAY", "1000"))
32
+ MCP_BROWSER_TIMEOUT = int(os.environ.get("MCP_BROWSER_TIMEOUT", "60"))
33
+
34
+ TRANSIENT_ERRORS = [
35
+ "ECONNREFUSED",
36
+ "ETIMEDOUT",
37
+ "ECONNRESET",
38
+ "EPIPE",
39
+ "timeout",
40
+ "Timeout",
41
+ "connection refused",
42
+ "socket hang up",
43
+ ]
44
+
17
45
 
18
46
  def is_browser_available() -> bool:
19
- """Check if browser tools should be registered."""
20
47
  if not BROWSER_ENABLED:
21
48
  return False
22
49
  if not BROWSER_AVAILABLE:
@@ -25,30 +52,109 @@ def is_browser_available() -> bool:
25
52
  return True
26
53
 
27
54
 
28
- def _run_browser(args: list, timeout: int = 60) -> Dict[str, Any]:
29
- """Execute agent-browser command."""
30
- cmd = ["agent-browser"] + args
55
+ def _debug(message: str) -> None:
56
+ if MCP_BROWSER_DEBUG:
57
+ logger.debug(f"[browser] {message}")
58
+
59
+
60
+ def _get_global_options() -> List[str]:
61
+ opts = []
62
+ if provider := os.environ.get("MCP_BROWSER_PROVIDER"):
63
+ opts.extend(["-p", provider])
64
+ if profile := os.environ.get("MCP_BROWSER_PROFILE"):
65
+ opts.extend(["--profile", os.path.expanduser(profile)])
66
+ if session := os.environ.get("MCP_BROWSER_SESSION"):
67
+ opts.extend(["--session", session])
68
+ if cdp := os.environ.get("MCP_BROWSER_CDP_URL"):
69
+ opts.extend(["--cdp", cdp])
70
+ if proxy := os.environ.get("MCP_BROWSER_PROXY"):
71
+ opts.extend(["--proxy", proxy])
72
+ if proxy_bypass := os.environ.get("MCP_BROWSER_PROXY_BYPASS"):
73
+ opts.extend(["--proxy-bypass", proxy_bypass])
74
+ if user_agent := os.environ.get("MCP_BROWSER_USER_AGENT"):
75
+ opts.extend(["--user-agent", user_agent])
76
+ if args := os.environ.get("MCP_BROWSER_ARGS"):
77
+ opts.extend(["--args", args])
78
+ if os.environ.get("MCP_BROWSER_HEADED", "").lower() in ("1", "true"):
79
+ opts.append("--headed")
80
+ return opts
81
+
82
+
83
+ def _is_transient_error(error_msg: str) -> bool:
84
+ return any(err.lower() in error_msg.lower() for err in TRANSIENT_ERRORS)
85
+
86
+
87
+ def _run_browser(
88
+ args: List[str],
89
+ timeout: int = None,
90
+ use_global_opts: bool = True
91
+ ) -> Dict[str, Any]:
92
+ timeout = timeout or MCP_BROWSER_TIMEOUT
93
+
94
+ cmd = ["agent-browser"]
95
+ if use_global_opts:
96
+ cmd.extend(_get_global_options())
97
+ cmd.extend(args)
98
+
99
+ _debug(f"Running: {' '.join(cmd)}")
100
+
31
101
  try:
32
102
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
103
+
33
104
  if result.returncode != 0:
34
- return {"success": False, "error": result.stderr.strip() or "Command failed"}
105
+ error_msg = result.stderr.strip() or "Command failed"
106
+ _debug(f"Command failed: {error_msg}")
107
+ return {"success": False, "error": error_msg}
108
+
35
109
  output = result.stdout.strip()
36
110
  if "--json" in args:
37
111
  try:
38
112
  return {"success": True, "data": json.loads(output)}
39
113
  except json.JSONDecodeError:
40
114
  return {"success": True, "output": output}
115
+
41
116
  return {"success": True, "output": output}
117
+
42
118
  except subprocess.TimeoutExpired:
119
+ _debug(f"Command timed out after {timeout}s")
43
120
  return {"success": False, "error": f"Command timed out after {timeout}s"}
44
121
  except FileNotFoundError:
45
- return {"success": False, "error": "agent-browser not found"}
122
+ return {"success": False, "error": "agent-browser not found. Install with: npm install -g agent-browser"}
46
123
  except Exception as e:
124
+ _debug(f"Command error: {e}")
47
125
  return {"success": False, "error": str(e)}
48
126
 
49
127
 
128
+ def _run_browser_with_retry(
129
+ args: List[str],
130
+ timeout: int = None,
131
+ max_retries: int = None,
132
+ use_global_opts: bool = True
133
+ ) -> Dict[str, Any]:
134
+ max_retries = max_retries if max_retries is not None else MCP_BROWSER_MAX_RETRIES
135
+ delay_ms = MCP_BROWSER_RETRY_DELAY
136
+
137
+ for attempt in range(max_retries + 1):
138
+ result = _run_browser(args, timeout=timeout, use_global_opts=use_global_opts)
139
+
140
+ if result.get("success"):
141
+ return result
142
+
143
+ error_msg = result.get("error", "")
144
+ if not _is_transient_error(error_msg):
145
+ return result
146
+ if attempt == max_retries:
147
+ _debug(f"Max retries ({max_retries}) exceeded")
148
+ return result
149
+
150
+ wait_time = (delay_ms * (2 ** attempt)) / 1000
151
+ _debug(f"Transient error, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})")
152
+ time.sleep(wait_time)
153
+
154
+ return result
155
+
156
+
50
157
  def _get_ingress_url(service: str, namespace: str) -> Optional[str]:
51
- """Get ingress URL for a service."""
52
158
  try:
53
159
  from kubernetes import client, config
54
160
  config.load_kube_config()
@@ -68,7 +174,6 @@ def _get_ingress_url(service: str, namespace: str) -> Optional[str]:
68
174
 
69
175
 
70
176
  def _get_service_url(service: str, namespace: str) -> Optional[str]:
71
- """Get service URL (LoadBalancer or NodePort)."""
72
177
  try:
73
178
  from kubernetes import client, config
74
179
  config.load_kube_config()
@@ -89,19 +194,55 @@ def _get_service_url(service: str, namespace: str) -> Optional[str]:
89
194
 
90
195
 
91
196
  def register_browser_tools(server, non_destructive: bool):
92
- """Register browser automation tools."""
197
+ _ = non_destructive
93
198
 
94
199
  @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])
200
+ def browser_open(
201
+ url: str,
202
+ wait_for: str = "networkidle",
203
+ headers: Optional[Dict[str, str]] = None,
204
+ session: Optional[str] = None,
205
+ headed: bool = False
206
+ ) -> Dict[str, Any]:
207
+ """Open a URL in the browser.
208
+
209
+ Args:
210
+ url: URL to navigate to
211
+ wait_for: Load state to wait for (load, domcontentloaded, networkidle)
212
+ headers: HTTP headers to set for this origin (v0.7+)
213
+ session: Session name for isolation (v0.7+)
214
+ headed: Show browser window (v0.7+)
215
+ """
216
+ # Build open command with optional headers
217
+ open_args = ["open", url]
218
+
219
+ if headers:
220
+ open_args.extend(["--headers", json.dumps(headers)])
221
+ if session:
222
+ open_args.extend(["--session", session])
223
+ if headed:
224
+ open_args.append("--headed")
225
+
226
+ result = _run_browser_with_retry(open_args)
98
227
  if result.get("success") and wait_for:
99
228
  _run_browser(["wait", "--load", wait_for])
100
229
  return {**result, "url": url}
101
230
 
102
231
  @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."""
232
+ def browser_snapshot(
233
+ interactive_only: bool = True,
234
+ compact: bool = True,
235
+ depth: Optional[int] = None,
236
+ selector: Optional[str] = None
237
+ ) -> Dict[str, Any]:
238
+ """Get accessibility tree snapshot of current page.
239
+
240
+ Args:
241
+ interactive_only: Only show interactive elements (buttons, inputs, links)
242
+ compact: Remove empty structural elements
243
+ depth: Limit tree depth
244
+ selector: Scope to CSS selector
245
+ """
105
246
  args = ["snapshot", "--json"]
106
247
  if interactive_only:
107
248
  args.append("-i")
@@ -109,6 +250,8 @@ def register_browser_tools(server, non_destructive: bool):
109
250
  args.append("-c")
110
251
  if depth:
111
252
  args.extend(["-d", str(depth)])
253
+ if selector:
254
+ args.extend(["-s", selector])
112
255
  return _run_browser(args)
113
256
 
114
257
  @server.tool(annotations=ToolAnnotations(title="Browser Click"))
@@ -122,11 +265,26 @@ def register_browser_tools(server, non_destructive: bool):
122
265
  return _run_browser(["fill", ref, text])
123
266
 
124
267
  @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."""
268
+ def browser_screenshot(
269
+ output_path: Optional[str] = None,
270
+ full_page: bool = False,
271
+ ref: Optional[str] = None
272
+ ) -> Dict[str, Any]:
273
+ """Take a screenshot of the current page or element.
274
+
275
+ Args:
276
+ output_path: Path to save screenshot (auto-generated if None)
277
+ full_page: Capture full scrollable page
278
+ ref: Element ref to screenshot (from snapshot)
279
+ """
280
+ if not output_path:
281
+ fd, output_path = tempfile.mkstemp(suffix=".png", prefix="screenshot_")
282
+ os.close(fd)
127
283
  args = ["screenshot", output_path]
128
284
  if full_page:
129
285
  args.append("--full")
286
+ if ref:
287
+ args.extend(["--ref", ref])
130
288
  result = _run_browser(args)
131
289
  return {**result, "path": output_path}
132
290
 
@@ -141,7 +299,11 @@ def register_browser_tools(server, non_destructive: bool):
141
299
  return _run_browser(["get", "url"])
142
300
 
143
301
  @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]:
302
+ def browser_wait(
303
+ selector: Optional[str] = None,
304
+ text: Optional[str] = None,
305
+ timeout_ms: int = 5000
306
+ ) -> Dict[str, Any]:
145
307
  """Wait for element, text, or timeout."""
146
308
  if text:
147
309
  return _run_browser(["wait", "--text", text], timeout=timeout_ms // 1000 + 5)
@@ -155,6 +317,129 @@ def register_browser_tools(server, non_destructive: bool):
155
317
  """Close the browser."""
156
318
  return _run_browser(["close"])
157
319
 
320
+ @server.tool(annotations=ToolAnnotations(title="Browser Connect CDP", readOnlyHint=True))
321
+ def browser_connect_cdp(
322
+ target: str,
323
+ session: Optional[str] = None
324
+ ) -> Dict[str, Any]:
325
+ """Connect to an existing browser via Chrome DevTools Protocol.
326
+
327
+ Args:
328
+ target: CDP port number (e.g., 9222) or WebSocket URL (wss://...)
329
+ session: Optional session name for this connection
330
+ """
331
+ args = ["connect", target]
332
+ if session:
333
+ args.extend(["--session", session])
334
+ return _run_browser_with_retry(args, use_global_opts=False)
335
+
336
+ @server.tool(annotations=ToolAnnotations(title="Browser Install"))
337
+ def browser_install(with_deps: bool = False) -> Dict[str, Any]:
338
+ """Install Chromium browser for agent-browser.
339
+
340
+ Args:
341
+ with_deps: Also install system dependencies (Linux only)
342
+ """
343
+ args = ["install"]
344
+ if with_deps:
345
+ args.append("--with-deps")
346
+ return _run_browser(args, timeout=300, use_global_opts=False)
347
+
348
+ @server.tool(annotations=ToolAnnotations(title="Browser Set Provider"))
349
+ def browser_set_provider(
350
+ provider: str,
351
+ api_key: Optional[str] = None,
352
+ project_id: Optional[str] = None
353
+ ) -> Dict[str, Any]:
354
+ """Configure cloud browser provider for current session.
355
+
356
+ Args:
357
+ provider: Provider name (browserbase, browseruse)
358
+ api_key: API key (or use env var BROWSERBASE_API_KEY / BROWSER_USE_API_KEY)
359
+ project_id: Project ID for Browserbase
360
+ """
361
+ # Set environment variables for the session
362
+ env_updates = {}
363
+ if provider.lower() == "browserbase":
364
+ if api_key:
365
+ env_updates["BROWSERBASE_API_KEY"] = api_key
366
+ if project_id:
367
+ env_updates["BROWSERBASE_PROJECT_ID"] = project_id
368
+ elif provider.lower() == "browseruse":
369
+ if api_key:
370
+ env_updates["BROWSER_USE_API_KEY"] = api_key
371
+
372
+ # Update environment
373
+ for key, value in env_updates.items():
374
+ os.environ[key] = value
375
+
376
+ return {
377
+ "success": True,
378
+ "provider": provider,
379
+ "message": f"Provider set to {provider}. Use -p {provider} flag or set MCP_BROWSER_PROVIDER={provider}"
380
+ }
381
+
382
+ @server.tool(annotations=ToolAnnotations(title="Browser Session List", readOnlyHint=True))
383
+ def browser_session_list() -> Dict[str, Any]:
384
+ """List active browser sessions."""
385
+ return _run_browser(["session", "list"], use_global_opts=False)
386
+
387
+ @server.tool(annotations=ToolAnnotations(title="Browser Session Switch"))
388
+ def browser_session_switch(session: str) -> Dict[str, Any]:
389
+ """Switch to a different browser session.
390
+
391
+ Args:
392
+ session: Session name to switch to
393
+ """
394
+ # Update the environment variable for subsequent commands
395
+ os.environ["MCP_BROWSER_SESSION"] = session
396
+ return {
397
+ "success": True,
398
+ "session": session,
399
+ "message": f"Switched to session: {session}"
400
+ }
401
+
402
+ @server.tool(annotations=ToolAnnotations(title="Browser Open With Headers", readOnlyHint=True))
403
+ def browser_open_with_headers(
404
+ url: str,
405
+ headers: Dict[str, str],
406
+ wait_for: str = "networkidle"
407
+ ) -> Dict[str, Any]:
408
+ """Open URL with custom HTTP headers (useful for authentication).
409
+
410
+ Headers are scoped to the URL's origin for security.
411
+
412
+ Args:
413
+ url: URL to navigate to
414
+ headers: HTTP headers to set (e.g., {"Authorization": "Bearer token"})
415
+ wait_for: Load state to wait for
416
+ """
417
+ args = ["open", url, "--headers", json.dumps(headers)]
418
+ result = _run_browser_with_retry(args)
419
+ if result.get("success") and wait_for:
420
+ _run_browser(["wait", "--load", wait_for])
421
+ return {**result, "url": url, "headers_set": list(headers.keys())}
422
+
423
+ @server.tool(annotations=ToolAnnotations(title="Browser Set Viewport"))
424
+ def browser_set_viewport(
425
+ width: Optional[int] = None,
426
+ height: Optional[int] = None,
427
+ device: Optional[str] = None
428
+ ) -> Dict[str, Any]:
429
+ """Set browser viewport size or emulate a device.
430
+
431
+ Args:
432
+ width: Viewport width in pixels
433
+ height: Viewport height in pixels
434
+ device: Device to emulate (e.g., "iPhone 14", "iPad Pro")
435
+ """
436
+ if device:
437
+ return _run_browser(["set", "device", device])
438
+ elif width and height:
439
+ return _run_browser(["set", "viewport", str(width), str(height)])
440
+ else:
441
+ return {"success": False, "error": "Specify device or both width and height"}
442
+
158
443
  @server.tool(annotations=ToolAnnotations(title="Test K8s Ingress", readOnlyHint=True))
159
444
  def browser_test_ingress(
160
445
  service_name: str,
@@ -170,7 +455,7 @@ def register_browser_tools(server, non_destructive: bool):
170
455
  return {"success": False, "error": f"No external URL found for {service_name} in {namespace}"}
171
456
 
172
457
  full_url = f"{url}{path}"
173
- open_result = _run_browser(["open", full_url])
458
+ open_result = _run_browser_with_retry(["open", full_url])
174
459
  if not open_result.get("success"):
175
460
  return {**open_result, "url": full_url}
176
461
 
@@ -209,7 +494,7 @@ def register_browser_tools(server, non_destructive: bool):
209
494
  return {"success": False, "error": f"No external URL found for {service_name} in {namespace}"}
210
495
 
211
496
  full_url = f"{url}{path}"
212
- _run_browser(["open", full_url])
497
+ _run_browser_with_retry(["open", full_url])
213
498
  _run_browser(["wait", "--load", "networkidle"])
214
499
  _run_browser(["wait", "2000"])
215
500
 
@@ -231,7 +516,7 @@ def register_browser_tools(server, non_destructive: bool):
231
516
  if dashboard_uid:
232
517
  url = f"{grafana_url.rstrip('/')}/d/{dashboard_uid}"
233
518
 
234
- _run_browser(["open", url])
519
+ _run_browser_with_retry(["open", url])
235
520
  _run_browser(["wait", "--load", "networkidle"])
236
521
  _run_browser(["wait", "3000"])
237
522
  result = _run_browser(["screenshot", "--full", output_path])
@@ -249,7 +534,7 @@ def register_browser_tools(server, non_destructive: bool):
249
534
  if app_name:
250
535
  url = f"{argocd_url.rstrip('/')}/applications/{app_name}"
251
536
 
252
- _run_browser(["open", url])
537
+ _run_browser_with_retry(["open", url])
253
538
  _run_browser(["wait", "--load", "networkidle"])
254
539
  _run_browser(["wait", "2000"])
255
540
  result = _run_browser(["screenshot", "--full", output_path])
@@ -263,7 +548,7 @@ def register_browser_tools(server, non_destructive: bool):
263
548
  check_elements: Optional[list] = None
264
549
  ) -> Dict[str, Any]:
265
550
  """Perform health check on a web application."""
266
- open_result = _run_browser(["open", url])
551
+ open_result = _run_browser_with_retry(["open", url])
267
552
  if not open_result.get("success"):
268
553
  return {**open_result, "url": url, "healthy": False}
269
554
 
@@ -302,7 +587,7 @@ def register_browser_tools(server, non_destructive: bool):
302
587
  submit_ref: Optional[str] = None
303
588
  ) -> Dict[str, Any]:
304
589
  """Fill and submit a web form."""
305
- _run_browser(["open", url])
590
+ _run_browser_with_retry(["open", url])
306
591
  _run_browser(["wait", "--load", "networkidle"])
307
592
 
308
593
  for ref, value in form_data.items():
@@ -355,17 +640,20 @@ def register_browser_tools(server, non_destructive: bool):
355
640
  if not url:
356
641
  return {"success": False, "error": f"Unknown provider: {provider}. Use eks, gke, aks, or do"}
357
642
 
358
- result = _run_browser(["open", url])
643
+ result = _run_browser_with_retry(["open", url])
359
644
  return {**result, "provider": provider, "url": url}
360
645
 
361
646
  @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]:
647
+ def browser_pdf_export(url: str, output_path: Optional[str] = None) -> Dict[str, Any]:
363
648
  """Export a web page as PDF."""
364
- _run_browser(["open", url])
649
+ if not output_path:
650
+ fd, output_path = tempfile.mkstemp(suffix=".pdf", prefix="page_")
651
+ os.close(fd)
652
+ _run_browser_with_retry(["open", url])
365
653
  _run_browser(["wait", "--load", "networkidle"])
366
654
  _run_browser(["wait", "2000"])
367
655
  result = _run_browser(["pdf", output_path])
368
656
  _run_browser(["close"])
369
657
  return {**result, "url": url, "path": output_path}
370
658
 
371
- logger.info("Registered 19 browser automation tools")
659
+ logger.info("Registered 26 browser automation tools (agent-browser v0.7+)")