kubectl-mcp-server 1.23.0__py3-none-any.whl → 1.23.1__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.
@@ -4,19 +4,12 @@ from typing import Any, Dict, List, Optional
4
4
 
5
5
  from mcp.types import ToolAnnotations
6
6
 
7
- from ..k8s_config import get_k8s_client, get_networking_client
7
+ from ..k8s_config import get_k8s_client, get_networking_client, _get_kubectl_context_args
8
8
 
9
9
  logger = logging.getLogger("mcp-server")
10
10
 
11
11
 
12
- def _get_kubectl_context_args(context: str) -> List[str]:
13
- """Get kubectl context arguments if context is specified."""
14
- if context:
15
- return ["--context", context]
16
- return []
17
-
18
-
19
- def register_networking_tools(server, non_destructive: bool):
12
+ def register_networking_tools(server: "FastMCP", non_destructive: bool):
20
13
  """Register networking-related tools."""
21
14
 
22
15
  @server.tool(
@@ -271,7 +264,7 @@ def register_networking_tools(server, non_destructive: bool):
271
264
  # Get endpoints
272
265
  try:
273
266
  endpoints = v1.read_namespaced_endpoints(service_name, namespace)
274
- except:
267
+ except Exception:
275
268
  endpoints = None
276
269
 
277
270
  # Get pods matching selector
@@ -354,11 +347,22 @@ def register_networking_tools(server, non_destructive: bool):
354
347
  namespace: Namespace of the pod
355
348
  context: Kubernetes context to use (uses current context if not specified)
356
349
  """
350
+ if non_destructive:
351
+ return {
352
+ "success": False,
353
+ "error": "port_forward is not allowed in non-destructive mode"
354
+ }
357
355
  try:
358
- import os
359
- ctx_args = " ".join(_get_kubectl_context_args(context))
360
- cmd = f"kubectl {ctx_args} port-forward {pod_name} {local_port}:{pod_port} -n {namespace} &"
361
- os.system(cmd)
356
+ cmd = ["kubectl"] + _get_kubectl_context_args(context) + [
357
+ "port-forward", pod_name,
358
+ f"{int(local_port)}:{int(pod_port)}",
359
+ "-n", namespace
360
+ ]
361
+ subprocess.Popen(
362
+ cmd,
363
+ stdout=subprocess.DEVNULL,
364
+ stderr=subprocess.DEVNULL,
365
+ )
362
366
  return {
363
367
  "success": True,
364
368
  "context": context or "current",
@@ -7,17 +7,12 @@ from typing import Any, Dict, List, Optional
7
7
 
8
8
  from mcp.types import ToolAnnotations
9
9
 
10
- logger = logging.getLogger("mcp-server")
11
-
10
+ from kubectl_mcp_tool.k8s_config import _get_kubectl_context_args
12
11
 
13
- def _get_kubectl_context_args(context: str) -> List[str]:
14
- """Get kubectl context arguments if context is specified."""
15
- if context:
16
- return ["--context", context]
17
- return []
12
+ logger = logging.getLogger("mcp-server")
18
13
 
19
14
 
20
- def register_operations_tools(server, non_destructive: bool):
15
+ def register_operations_tools(server: "FastMCP", non_destructive: bool):
21
16
  """Register kubectl operations tools (apply, describe, patch, etc.)."""
22
17
 
23
18
  def check_destructive():
@@ -43,6 +38,7 @@ def register_operations_tools(server, non_destructive: bool):
43
38
  blocked = check_destructive()
44
39
  if blocked:
45
40
  return blocked
41
+ temp_path = None
46
42
  try:
47
43
  with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
48
44
  f.write(manifest)
@@ -51,8 +47,6 @@ def register_operations_tools(server, non_destructive: bool):
51
47
  cmd = ["kubectl"] + _get_kubectl_context_args(context) + ["apply", "-f", temp_path, "-n", namespace]
52
48
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
53
49
 
54
- os.unlink(temp_path)
55
-
56
50
  if result.returncode == 0:
57
51
  return {"success": True, "context": context or "current", "output": result.stdout.strip()}
58
52
  else:
@@ -60,6 +54,12 @@ def register_operations_tools(server, non_destructive: bool):
60
54
  except Exception as e:
61
55
  logger.error(f"Error applying manifest: {e}")
62
56
  return {"success": False, "error": str(e)}
57
+ finally:
58
+ if temp_path:
59
+ try:
60
+ os.unlink(temp_path)
61
+ except OSError:
62
+ pass
63
63
 
64
64
  @server.tool(
65
65
  annotations=ToolAnnotations(
@@ -105,7 +105,12 @@ def register_operations_tools(server, non_destructive: bool):
105
105
  # Security: validate command starts with allowed operations
106
106
  allowed_prefixes = [
107
107
  "get", "describe", "logs", "top", "explain", "api-resources",
108
- "config", "version", "cluster-info", "auth"
108
+ "version", "cluster-info"
109
+ ]
110
+ allowed_auth_subcommands = ["can-i"]
111
+ allowed_config_subcommands = [
112
+ "config view", "config current-context", "config get-contexts",
113
+ "config get-clusters", "config use-context"
109
114
  ]
110
115
  cmd_parts = shlex.split(command)
111
116
  if not cmd_parts:
@@ -115,10 +120,31 @@ def register_operations_tools(server, non_destructive: bool):
115
120
  if cmd_parts[0] == "kubectl":
116
121
  cmd_parts = cmd_parts[1:]
117
122
 
118
- if not cmd_parts or cmd_parts[0] not in allowed_prefixes:
123
+ if not cmd_parts:
124
+ return {"success": False, "error": "Empty command"}
125
+
126
+ if cmd_parts[0] == "config":
127
+ cmd_prefix = " ".join(cmd_parts[:2])
128
+ if cmd_prefix not in allowed_config_subcommands:
129
+ return {
130
+ "success": False,
131
+ "error": f"Config subcommand not allowed. Allowed: {', '.join(allowed_config_subcommands)}"
132
+ }
133
+ if "--raw" in cmd_parts:
134
+ return {
135
+ "success": False,
136
+ "error": "config view --raw is not allowed (may expose secrets)"
137
+ }
138
+ elif cmd_parts[0] == "auth":
139
+ if len(cmd_parts) < 2 or cmd_parts[1] not in allowed_auth_subcommands:
140
+ return {
141
+ "success": False,
142
+ "error": f"Auth subcommand not allowed. Allowed: {', '.join(allowed_auth_subcommands)}"
143
+ }
144
+ elif cmd_parts[0] not in allowed_prefixes:
119
145
  return {
120
146
  "success": False,
121
- "error": f"Command not allowed. Allowed: {', '.join(allowed_prefixes)}"
147
+ "error": f"Command not allowed. Allowed: {', '.join(allowed_prefixes + ['config', 'auth can-i'])}"
122
148
  }
123
149
 
124
150
  full_cmd = ["kubectl"] + _get_kubectl_context_args(context) + cmd_parts
@@ -8,24 +8,17 @@ import json
8
8
  import logging
9
9
  import shlex
10
10
  import subprocess
11
- from typing import Any, Callable, Dict, List, Optional
11
+ from typing import Any, Dict, List, Optional
12
12
 
13
13
  from mcp.types import ToolAnnotations
14
14
 
15
- from kubectl_mcp_tool.k8s_config import get_k8s_client
15
+ from kubectl_mcp_tool.k8s_config import get_k8s_client, _get_kubectl_context_args
16
16
 
17
17
  logger = logging.getLogger("mcp-server")
18
18
 
19
19
 
20
- def _get_kubectl_context_args(context: str = "") -> List[str]:
21
- """Get kubectl context arguments."""
22
- if context:
23
- return ["--context", context]
24
- return []
25
-
26
-
27
20
  def register_pod_tools(
28
- server,
21
+ server: "FastMCP",
29
22
  non_destructive: bool
30
23
  ):
31
24
  """Register all Pod-related tools with the MCP server.
@@ -210,6 +203,8 @@ def register_pod_tools(
210
203
  container: Container name (for multi-container pods)
211
204
  context: Kubernetes context to use (uses current context if not specified)
212
205
  """
206
+ if non_destructive:
207
+ return {"success": False, "error": "Blocked: non-destructive mode"}
213
208
  try:
214
209
  cmd = ["kubectl"] + _get_kubectl_context_args(context) + [
215
210
  "exec", pod_name, "-n", namespace
@@ -778,8 +773,10 @@ def register_pod_tools(
778
773
  },
779
774
  "evictedPods": evicted,
780
775
  "recommendations": [
781
- "DiskPressure: Clean up disk space or increase ephemeral-storage limits" if "DiskPressure" in by_reason else None,
782
- "MemoryPressure: Increase memory limits or add more nodes" if "MemoryPressure" in by_reason else None
776
+ rec for rec in [
777
+ "DiskPressure: Clean up disk space or increase ephemeral-storage limits" if "DiskPressure" in by_reason else None,
778
+ "MemoryPressure: Increase memory limits or add more nodes" if "MemoryPressure" in by_reason else None
779
+ ] if rec is not None
783
780
  ]
784
781
  }
785
782
  except Exception as e:
@@ -9,12 +9,7 @@ import json
9
9
  import re
10
10
  from typing import Dict, Any, List, Optional
11
11
 
12
- try:
13
- from fastmcp import FastMCP
14
- from fastmcp.tools import ToolAnnotations
15
- except ImportError:
16
- from mcp.server.fastmcp import FastMCP
17
- from mcp.types import ToolAnnotations
12
+ from mcp.types import ToolAnnotations
18
13
 
19
14
 
20
15
  def _vcluster_available() -> bool:
@@ -584,7 +579,7 @@ def vind_platform_start(
584
579
  return result
585
580
 
586
581
 
587
- def register_vind_tools(mcp: FastMCP, non_destructive: bool = False):
582
+ def register_vind_tools(mcp: "FastMCP", non_destructive: bool = False):
588
583
  """Register vind (vCluster in Docker) tools with the MCP server."""
589
584
 
590
585
  @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
tests/test_browser.py CHANGED
@@ -501,11 +501,10 @@ class TestServerIntegration:
501
501
  tools = asyncio.run(server.server.list_tools())
502
502
  tool_names = [t.name for t in tools]
503
503
 
504
- # Should have browser tools (224 + 26 = 250)
505
504
  assert "browser_open" in tool_names
506
505
  assert "browser_screenshot" in tool_names
507
- assert "browser_connect_cdp" in tool_names # v0.7 tool
508
- assert len(tools) == 250, f"Expected 250 tools (224 + 26), got {len(tools)}"
506
+ assert "browser_connect_cdp" in tool_names
507
+ assert len(tools) == 301, f"Expected 301 tools (275 + 26), got {len(tools)}"
509
508
 
510
509
 
511
510
  import asyncio
tests/test_ecosystem.py CHANGED
@@ -153,9 +153,8 @@ class TestGitOpsTools:
153
153
  from kubectl_mcp_tool.mcp_server import MCPServer
154
154
 
155
155
  with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
156
- server = MCPServer(name="test", non_destructive=True)
156
+ server = MCPServer(name="test", disable_destructive=True)
157
157
 
158
- # Server should initialize with non_destructive=True
159
158
  assert server.non_destructive is True
160
159
 
161
160
 
@@ -263,9 +262,8 @@ class TestBackupTools:
263
262
  from kubectl_mcp_tool.mcp_server import MCPServer
264
263
 
265
264
  with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
266
- server = MCPServer(name="test", non_destructive=True)
265
+ server = MCPServer(name="test", disable_destructive=True)
267
266
 
268
- # Server should initialize with non_destructive=True
269
267
  assert server.non_destructive is True
270
268
 
271
269
 
@@ -205,7 +205,7 @@ class TestMCPServerSafety:
205
205
  """Test safety mode integration in MCP server."""
206
206
 
207
207
  def test_safety_mode_info(self):
208
- """Test safety mode info retrieval."""
208
+ """Test safety mode info for all modes."""
209
209
  from kubectl_mcp_tool.safety import (
210
210
  SafetyMode,
211
211
  set_safety_mode,
@@ -223,29 +223,7 @@ class TestMCPServerSafety:
223
223
  assert info["mode"] == "read_only"
224
224
  assert len(info["blocked_operations"]) > 0
225
225
 
226
- def test_operation_allowed_check(self):
227
- """Test operation allowed check."""
228
- from kubectl_mcp_tool.safety import (
229
- SafetyMode,
230
- set_safety_mode,
231
- is_operation_allowed,
232
- )
233
-
234
- set_safety_mode(SafetyMode.NORMAL)
235
- allowed, reason = is_operation_allowed("delete_pod")
236
- assert allowed is True
237
- assert reason == ""
238
-
239
- set_safety_mode(SafetyMode.READ_ONLY)
240
- allowed, reason = is_operation_allowed("delete_pod")
241
- assert allowed is False
242
- assert "blocked" in reason.lower()
243
-
244
226
  set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
245
- allowed, reason = is_operation_allowed("delete_pod")
246
- assert allowed is False
247
- assert "blocked" in reason.lower()
248
-
249
- # Non-destructive write should be allowed
250
- allowed, reason = is_operation_allowed("create_deployment")
251
- assert allowed is True
227
+ info = get_mode_info()
228
+ assert info["mode"] == "disable_destructive"
229
+ assert "delete_pod" in info["blocked_operations"]
tests/test_safety.py CHANGED
@@ -5,8 +5,6 @@ from kubectl_mcp_tool.safety import (
5
5
  SafetyMode,
6
6
  get_safety_mode,
7
7
  set_safety_mode,
8
- is_operation_allowed,
9
- check_safety_mode,
10
8
  get_mode_info,
11
9
  WRITE_OPERATIONS,
12
10
  DESTRUCTIVE_OPERATIONS,
@@ -35,124 +33,6 @@ class TestSafetyMode:
35
33
  assert get_safety_mode() == SafetyMode.DISABLE_DESTRUCTIVE
36
34
 
37
35
 
38
- class TestOperationAllowed:
39
- """Test is_operation_allowed function."""
40
-
41
- def setup_method(self):
42
- """Reset safety mode to NORMAL before each test."""
43
- set_safety_mode(SafetyMode.NORMAL)
44
-
45
- def test_all_operations_allowed_in_normal_mode(self):
46
- """Test that all operations are allowed in NORMAL mode."""
47
- for op in WRITE_OPERATIONS | DESTRUCTIVE_OPERATIONS:
48
- allowed, reason = is_operation_allowed(op)
49
- assert allowed is True
50
- assert reason == ""
51
-
52
- def test_read_operations_allowed_in_all_modes(self):
53
- """Test that read operations are allowed in all modes."""
54
- read_ops = ["get_pods", "list_namespaces", "describe_deployment", "get_logs"]
55
-
56
- for mode in SafetyMode:
57
- set_safety_mode(mode)
58
- for op in read_ops:
59
- allowed, reason = is_operation_allowed(op)
60
- assert allowed is True
61
-
62
- def test_write_operations_blocked_in_read_only_mode(self):
63
- """Test that write operations are blocked in READ_ONLY mode."""
64
- set_safety_mode(SafetyMode.READ_ONLY)
65
-
66
- for op in WRITE_OPERATIONS:
67
- allowed, reason = is_operation_allowed(op)
68
- assert allowed is False
69
- assert "read-only mode" in reason
70
-
71
- def test_destructive_operations_blocked_in_read_only_mode(self):
72
- """Test that destructive operations are blocked in READ_ONLY mode."""
73
- set_safety_mode(SafetyMode.READ_ONLY)
74
-
75
- for op in DESTRUCTIVE_OPERATIONS:
76
- allowed, reason = is_operation_allowed(op)
77
- assert allowed is False
78
- assert "read-only mode" in reason
79
-
80
- def test_write_operations_allowed_in_disable_destructive_mode(self):
81
- """Test that non-destructive write operations are allowed in DISABLE_DESTRUCTIVE mode."""
82
- set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
83
-
84
- # Operations that are write but not destructive
85
- non_destructive_writes = WRITE_OPERATIONS - DESTRUCTIVE_OPERATIONS
86
- for op in non_destructive_writes:
87
- allowed, reason = is_operation_allowed(op)
88
- assert allowed is True
89
-
90
- def test_destructive_operations_blocked_in_disable_destructive_mode(self):
91
- """Test that destructive operations are blocked in DISABLE_DESTRUCTIVE mode."""
92
- set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
93
-
94
- for op in DESTRUCTIVE_OPERATIONS:
95
- allowed, reason = is_operation_allowed(op)
96
- assert allowed is False
97
- assert "destructive operations are disabled" in reason
98
-
99
-
100
- class TestCheckSafetyModeDecorator:
101
- """Test the check_safety_mode decorator."""
102
-
103
- def setup_method(self):
104
- """Reset safety mode to NORMAL before each test."""
105
- set_safety_mode(SafetyMode.NORMAL)
106
-
107
- def test_decorator_allows_in_normal_mode(self):
108
- """Test that decorated function executes in NORMAL mode."""
109
- @check_safety_mode
110
- def delete_pod():
111
- return {"success": True, "message": "Pod deleted"}
112
-
113
- result = delete_pod()
114
- assert result["success"] is True
115
- assert result["message"] == "Pod deleted"
116
-
117
- def test_decorator_blocks_write_in_read_only_mode(self):
118
- """Test that decorated function is blocked in READ_ONLY mode."""
119
- set_safety_mode(SafetyMode.READ_ONLY)
120
-
121
- @check_safety_mode
122
- def run_pod():
123
- return {"success": True, "message": "Pod created"}
124
-
125
- result = run_pod()
126
- assert result["success"] is False
127
- assert "blocked" in result["error"]
128
- assert result["blocked_by"] == "read_only"
129
- assert result["operation"] == "run_pod"
130
-
131
- def test_decorator_blocks_destructive_in_disable_destructive_mode(self):
132
- """Test that destructive operations are blocked."""
133
- set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
134
-
135
- @check_safety_mode
136
- def delete_deployment():
137
- return {"success": True, "message": "Deployment deleted"}
138
-
139
- result = delete_deployment()
140
- assert result["success"] is False
141
- assert "blocked" in result["error"]
142
- assert result["blocked_by"] == "disable_destructive"
143
-
144
- def test_decorator_allows_non_destructive_write_in_disable_destructive_mode(self):
145
- """Test that non-destructive writes are allowed in DISABLE_DESTRUCTIVE mode."""
146
- set_safety_mode(SafetyMode.DISABLE_DESTRUCTIVE)
147
-
148
- @check_safety_mode
149
- def scale_deployment():
150
- return {"success": True, "message": "Deployment scaled"}
151
-
152
- result = scale_deployment()
153
- assert result["success"] is True
154
-
155
-
156
36
  class TestGetModeInfo:
157
37
  """Test get_mode_info function."""
158
38
 
tests/test_server.py CHANGED
@@ -57,7 +57,7 @@ class TestServerInitialization:
57
57
 
58
58
  with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
59
59
  with patch("kubernetes.config.load_kube_config"):
60
- server = MCPServer(name="test", non_destructive=True)
60
+ server = MCPServer(name="test", disable_destructive=True)
61
61
 
62
62
  assert server.non_destructive is True
63
63
 
@@ -215,7 +215,7 @@ class TestNonDestructiveMode:
215
215
 
216
216
  with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
217
217
  with patch("kubernetes.config.load_kube_config"):
218
- server = MCPServer(name="test", non_destructive=False)
218
+ server = MCPServer(name="test")
219
219
 
220
220
  result = server._check_destructive()
221
221
  assert result is None
@@ -227,12 +227,11 @@ class TestNonDestructiveMode:
227
227
 
228
228
  with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies", return_value=True):
229
229
  with patch("kubernetes.config.load_kube_config"):
230
- server = MCPServer(name="test", non_destructive=True)
230
+ server = MCPServer(name="test", disable_destructive=True)
231
231
 
232
232
  result = server._check_destructive()
233
233
  assert result is not None
234
234
  assert result["success"] is False
235
- assert "non-destructive mode" in result["error"]
236
235
 
237
236
 
238
237
  class TestSecretMasking:
@@ -349,7 +348,7 @@ class TestServerConfiguration:
349
348
  with patch("kubernetes.config.load_kube_config"):
350
349
  server = MCPServer(
351
350
  name="custom-server",
352
- non_destructive=True
351
+ disable_destructive=True
353
352
  )
354
353
 
355
354
  assert server.name == "custom-server"