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
tests/conftest.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest configuration and shared fixtures for kubectl-mcp-server tests.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from unittest.mock import Mock, MagicMock, patch
|
|
9
|
+
from typing import Dict, Any, List
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mock_kube_config():
|
|
15
|
+
"""Mock kubernetes config loading."""
|
|
16
|
+
with patch("kubernetes.config.load_kube_config") as mock:
|
|
17
|
+
mock.return_value = None
|
|
18
|
+
yield mock
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def mock_kube_contexts():
|
|
23
|
+
"""Mock kubernetes contexts."""
|
|
24
|
+
contexts = [
|
|
25
|
+
{
|
|
26
|
+
"name": "minikube",
|
|
27
|
+
"context": {
|
|
28
|
+
"cluster": "minikube",
|
|
29
|
+
"user": "minikube",
|
|
30
|
+
"namespace": "default"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "production",
|
|
35
|
+
"context": {
|
|
36
|
+
"cluster": "prod-cluster",
|
|
37
|
+
"user": "admin",
|
|
38
|
+
"namespace": "production"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
active_context = contexts[0]
|
|
43
|
+
|
|
44
|
+
with patch("kubernetes.config.list_kube_config_contexts") as mock:
|
|
45
|
+
mock.return_value = (contexts, active_context)
|
|
46
|
+
yield mock
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.fixture
|
|
50
|
+
def mock_pod():
|
|
51
|
+
"""Create a mock Pod object."""
|
|
52
|
+
pod = MagicMock()
|
|
53
|
+
pod.metadata.name = "test-pod"
|
|
54
|
+
pod.metadata.namespace = "default"
|
|
55
|
+
pod.metadata.labels = {"app": "test"}
|
|
56
|
+
pod.metadata.creation_timestamp = datetime.now()
|
|
57
|
+
pod.status.phase = "Running"
|
|
58
|
+
pod.status.pod_ip = "10.0.0.1"
|
|
59
|
+
pod.status.conditions = []
|
|
60
|
+
pod.spec.containers = [MagicMock(name="container1", image="nginx:latest")]
|
|
61
|
+
pod.spec.node_name = "node-1"
|
|
62
|
+
return pod
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def mock_deployment():
|
|
67
|
+
"""Create a mock Deployment object."""
|
|
68
|
+
deployment = MagicMock()
|
|
69
|
+
deployment.metadata.name = "test-deployment"
|
|
70
|
+
deployment.metadata.namespace = "default"
|
|
71
|
+
deployment.metadata.labels = {"app": "test"}
|
|
72
|
+
deployment.metadata.creation_timestamp = datetime.now()
|
|
73
|
+
deployment.spec.replicas = 3
|
|
74
|
+
deployment.status.ready_replicas = 3
|
|
75
|
+
deployment.status.available_replicas = 3
|
|
76
|
+
deployment.status.replicas = 3
|
|
77
|
+
return deployment
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.fixture
|
|
81
|
+
def mock_service():
|
|
82
|
+
"""Create a mock Service object."""
|
|
83
|
+
service = MagicMock()
|
|
84
|
+
service.metadata.name = "test-service"
|
|
85
|
+
service.metadata.namespace = "default"
|
|
86
|
+
service.spec.type = "ClusterIP"
|
|
87
|
+
service.spec.cluster_ip = "10.96.0.1"
|
|
88
|
+
service.spec.ports = [MagicMock(port=80, target_port=8080, protocol="TCP")]
|
|
89
|
+
return service
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture
|
|
93
|
+
def mock_node():
|
|
94
|
+
"""Create a mock Node object."""
|
|
95
|
+
node = MagicMock()
|
|
96
|
+
node.metadata.name = "test-node"
|
|
97
|
+
node.metadata.labels = {"node-role.kubernetes.io/control-plane": ""}
|
|
98
|
+
node.status.conditions = [MagicMock(type="Ready", status="True")]
|
|
99
|
+
node.status.node_info.kubelet_version = "v1.28.0"
|
|
100
|
+
node.status.node_info.os_image = "Ubuntu 22.04"
|
|
101
|
+
node.status.node_info.architecture = "amd64"
|
|
102
|
+
node.status.capacity = {"cpu": "4", "memory": "8Gi", "pods": "110"}
|
|
103
|
+
node.status.allocatable = {"cpu": "3800m", "memory": "7Gi", "pods": "100"}
|
|
104
|
+
return node
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.fixture
|
|
108
|
+
def mock_namespace():
|
|
109
|
+
"""Create a mock Namespace object."""
|
|
110
|
+
namespace = MagicMock()
|
|
111
|
+
namespace.metadata.name = "test-namespace"
|
|
112
|
+
namespace.metadata.labels = {"env": "test"}
|
|
113
|
+
namespace.metadata.creation_timestamp = datetime.now()
|
|
114
|
+
namespace.status.phase = "Active"
|
|
115
|
+
return namespace
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.fixture
|
|
119
|
+
def mock_configmap():
|
|
120
|
+
"""Create a mock ConfigMap object."""
|
|
121
|
+
configmap = MagicMock()
|
|
122
|
+
configmap.metadata.name = "test-configmap"
|
|
123
|
+
configmap.metadata.namespace = "default"
|
|
124
|
+
configmap.data = {"key1": "value1", "key2": "value2"}
|
|
125
|
+
return configmap
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.fixture
|
|
129
|
+
def mock_secret():
|
|
130
|
+
"""Create a mock Secret object."""
|
|
131
|
+
secret = MagicMock()
|
|
132
|
+
secret.metadata.name = "test-secret"
|
|
133
|
+
secret.metadata.namespace = "default"
|
|
134
|
+
secret.type = "Opaque"
|
|
135
|
+
secret.data = {"password": "c2VjcmV0"} # base64 encoded
|
|
136
|
+
return secret
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.fixture
|
|
140
|
+
def mock_core_v1_api(mock_pod, mock_service, mock_namespace, mock_configmap, mock_secret, mock_node):
|
|
141
|
+
"""Mock CoreV1Api."""
|
|
142
|
+
api = MagicMock()
|
|
143
|
+
|
|
144
|
+
# Pod methods
|
|
145
|
+
api.list_namespaced_pod.return_value.items = [mock_pod]
|
|
146
|
+
api.list_pod_for_all_namespaces.return_value.items = [mock_pod]
|
|
147
|
+
api.read_namespaced_pod.return_value = mock_pod
|
|
148
|
+
api.read_namespaced_pod_log.return_value = "Test log output"
|
|
149
|
+
|
|
150
|
+
# Service methods
|
|
151
|
+
api.list_namespaced_service.return_value.items = [mock_service]
|
|
152
|
+
api.list_service_for_all_namespaces.return_value.items = [mock_service]
|
|
153
|
+
api.read_namespaced_service.return_value = mock_service
|
|
154
|
+
|
|
155
|
+
# Namespace methods
|
|
156
|
+
api.list_namespace.return_value.items = [mock_namespace]
|
|
157
|
+
api.read_namespace.return_value = mock_namespace
|
|
158
|
+
api.create_namespace.return_value = mock_namespace
|
|
159
|
+
|
|
160
|
+
# ConfigMap methods
|
|
161
|
+
api.list_namespaced_config_map.return_value.items = [mock_configmap]
|
|
162
|
+
api.read_namespaced_config_map.return_value = mock_configmap
|
|
163
|
+
|
|
164
|
+
# Secret methods
|
|
165
|
+
api.list_namespaced_secret.return_value.items = [mock_secret]
|
|
166
|
+
api.read_namespaced_secret.return_value = mock_secret
|
|
167
|
+
|
|
168
|
+
# Node methods
|
|
169
|
+
api.list_node.return_value.items = [mock_node]
|
|
170
|
+
api.read_node.return_value = mock_node
|
|
171
|
+
|
|
172
|
+
# Events
|
|
173
|
+
api.list_namespaced_event.return_value.items = []
|
|
174
|
+
|
|
175
|
+
return api
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.fixture
|
|
179
|
+
def mock_apps_v1_api(mock_deployment):
|
|
180
|
+
"""Mock AppsV1Api."""
|
|
181
|
+
api = MagicMock()
|
|
182
|
+
|
|
183
|
+
api.list_namespaced_deployment.return_value.items = [mock_deployment]
|
|
184
|
+
api.list_deployment_for_all_namespaces.return_value.items = [mock_deployment]
|
|
185
|
+
api.read_namespaced_deployment.return_value = mock_deployment
|
|
186
|
+
api.read_namespaced_deployment_scale.return_value.spec.replicas = 3
|
|
187
|
+
|
|
188
|
+
api.list_namespaced_stateful_set.return_value.items = []
|
|
189
|
+
api.list_namespaced_daemon_set.return_value.items = []
|
|
190
|
+
api.list_namespaced_replica_set.return_value.items = []
|
|
191
|
+
|
|
192
|
+
return api
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.fixture
|
|
196
|
+
def mock_networking_v1_api():
|
|
197
|
+
"""Mock NetworkingV1Api."""
|
|
198
|
+
api = MagicMock()
|
|
199
|
+
|
|
200
|
+
ingress = MagicMock()
|
|
201
|
+
ingress.metadata.name = "test-ingress"
|
|
202
|
+
ingress.metadata.namespace = "default"
|
|
203
|
+
api.list_namespaced_ingress.return_value.items = [ingress]
|
|
204
|
+
api.read_namespaced_ingress.return_value = ingress
|
|
205
|
+
|
|
206
|
+
network_policy = MagicMock()
|
|
207
|
+
network_policy.metadata.name = "test-policy"
|
|
208
|
+
api.list_namespaced_network_policy.return_value.items = [network_policy]
|
|
209
|
+
|
|
210
|
+
return api
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.fixture
|
|
214
|
+
def mock_batch_v1_api():
|
|
215
|
+
"""Mock BatchV1Api."""
|
|
216
|
+
api = MagicMock()
|
|
217
|
+
|
|
218
|
+
job = MagicMock()
|
|
219
|
+
job.metadata.name = "test-job"
|
|
220
|
+
job.metadata.namespace = "default"
|
|
221
|
+
api.list_namespaced_job.return_value.items = [job]
|
|
222
|
+
|
|
223
|
+
cronjob = MagicMock()
|
|
224
|
+
cronjob.metadata.name = "test-cronjob"
|
|
225
|
+
api.list_namespaced_cron_job.return_value.items = [cronjob]
|
|
226
|
+
|
|
227
|
+
return api
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@pytest.fixture
|
|
231
|
+
def mock_version_api():
|
|
232
|
+
"""Mock VersionApi."""
|
|
233
|
+
api = MagicMock()
|
|
234
|
+
|
|
235
|
+
version_info = MagicMock()
|
|
236
|
+
version_info.git_version = "v1.28.0"
|
|
237
|
+
version_info.major = "1"
|
|
238
|
+
version_info.minor = "28"
|
|
239
|
+
version_info.platform = "linux/amd64"
|
|
240
|
+
version_info.build_date = "2024-01-01T00:00:00Z"
|
|
241
|
+
version_info.go_version = "go1.21.0"
|
|
242
|
+
version_info.compiler = "gc"
|
|
243
|
+
|
|
244
|
+
api.get_code.return_value = version_info
|
|
245
|
+
return api
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.fixture
|
|
249
|
+
def mock_kubectl_subprocess():
|
|
250
|
+
"""Mock subprocess calls for kubectl."""
|
|
251
|
+
with patch("subprocess.run") as mock_run:
|
|
252
|
+
mock_run.return_value = MagicMock(
|
|
253
|
+
returncode=0,
|
|
254
|
+
stdout="Command executed successfully",
|
|
255
|
+
stderr=""
|
|
256
|
+
)
|
|
257
|
+
yield mock_run
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@pytest.fixture
|
|
261
|
+
def mock_helm_subprocess():
|
|
262
|
+
"""Mock subprocess calls for helm."""
|
|
263
|
+
with patch("subprocess.run") as mock_run:
|
|
264
|
+
mock_run.return_value = MagicMock(
|
|
265
|
+
returncode=0,
|
|
266
|
+
stdout=json.dumps([
|
|
267
|
+
{"name": "test-release", "namespace": "default", "status": "deployed"}
|
|
268
|
+
]),
|
|
269
|
+
stderr=""
|
|
270
|
+
)
|
|
271
|
+
yield mock_run
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@pytest.fixture
|
|
275
|
+
def mcp_server(mock_kube_config):
|
|
276
|
+
"""Create an MCPServer instance with mocked dependencies."""
|
|
277
|
+
with patch("kubectl_mcp_tool.mcp_server.MCPServer._check_dependencies") as mock_deps:
|
|
278
|
+
mock_deps.return_value = True
|
|
279
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
280
|
+
server = MCPServer(name="test-server")
|
|
281
|
+
yield server
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.fixture
|
|
285
|
+
def mock_all_kubernetes_apis(
|
|
286
|
+
mock_kube_config,
|
|
287
|
+
mock_kube_contexts,
|
|
288
|
+
mock_core_v1_api,
|
|
289
|
+
mock_apps_v1_api,
|
|
290
|
+
mock_networking_v1_api,
|
|
291
|
+
mock_batch_v1_api,
|
|
292
|
+
mock_version_api
|
|
293
|
+
):
|
|
294
|
+
"""Patch all Kubernetes API clients."""
|
|
295
|
+
with patch("kubernetes.client.CoreV1Api", return_value=mock_core_v1_api), \
|
|
296
|
+
patch("kubernetes.client.AppsV1Api", return_value=mock_apps_v1_api), \
|
|
297
|
+
patch("kubernetes.client.NetworkingV1Api", return_value=mock_networking_v1_api), \
|
|
298
|
+
patch("kubernetes.client.BatchV1Api", return_value=mock_batch_v1_api), \
|
|
299
|
+
patch("kubernetes.client.VersionApi", return_value=mock_version_api), \
|
|
300
|
+
patch("kubernetes.client.ApiClient") as mock_api_client:
|
|
301
|
+
|
|
302
|
+
mock_api_client.return_value.sanitize_for_serialization.return_value = {}
|
|
303
|
+
|
|
304
|
+
yield {
|
|
305
|
+
"core_v1": mock_core_v1_api,
|
|
306
|
+
"apps_v1": mock_apps_v1_api,
|
|
307
|
+
"networking_v1": mock_networking_v1_api,
|
|
308
|
+
"batch_v1": mock_batch_v1_api,
|
|
309
|
+
"version": mock_version_api,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class MockResponse:
|
|
314
|
+
"""Mock HTTP response for testing."""
|
|
315
|
+
def __init__(self, json_data: Dict, status_code: int = 200):
|
|
316
|
+
self.json_data = json_data
|
|
317
|
+
self.status_code = status_code
|
|
318
|
+
self.text = json.dumps(json_data)
|
|
319
|
+
|
|
320
|
+
def json(self):
|
|
321
|
+
return self.json_data
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@pytest.fixture
|
|
325
|
+
def sample_pod_yaml():
|
|
326
|
+
"""Sample pod YAML for testing."""
|
|
327
|
+
return """
|
|
328
|
+
apiVersion: v1
|
|
329
|
+
kind: Pod
|
|
330
|
+
metadata:
|
|
331
|
+
name: test-pod
|
|
332
|
+
namespace: default
|
|
333
|
+
spec:
|
|
334
|
+
containers:
|
|
335
|
+
- name: nginx
|
|
336
|
+
image: nginx:latest
|
|
337
|
+
ports:
|
|
338
|
+
- containerPort: 80
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.fixture
|
|
343
|
+
def sample_deployment_yaml():
|
|
344
|
+
"""Sample deployment YAML for testing."""
|
|
345
|
+
return """
|
|
346
|
+
apiVersion: apps/v1
|
|
347
|
+
kind: Deployment
|
|
348
|
+
metadata:
|
|
349
|
+
name: test-deployment
|
|
350
|
+
namespace: default
|
|
351
|
+
spec:
|
|
352
|
+
replicas: 3
|
|
353
|
+
selector:
|
|
354
|
+
matchLabels:
|
|
355
|
+
app: test
|
|
356
|
+
template:
|
|
357
|
+
metadata:
|
|
358
|
+
labels:
|
|
359
|
+
app: test
|
|
360
|
+
spec:
|
|
361
|
+
containers:
|
|
362
|
+
- name: nginx
|
|
363
|
+
image: nginx:latest
|
|
364
|
+
ports:
|
|
365
|
+
- containerPort: 80
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def pytest_configure(config):
|
|
370
|
+
"""Configure pytest markers."""
|
|
371
|
+
config.addinivalue_line(
|
|
372
|
+
"markers", "unit: mark test as a unit test"
|
|
373
|
+
)
|
|
374
|
+
config.addinivalue_line(
|
|
375
|
+
"markers", "integration: mark test as an integration test"
|
|
376
|
+
)
|
|
377
|
+
config.addinivalue_line(
|
|
378
|
+
"markers", "slow: mark test as slow running"
|
|
379
|
+
)
|
tests/test_auth.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Tests for the MCP Authorization module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pytest
|
|
5
|
+
from unittest.mock import patch, MagicMock
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestAuthConfig:
|
|
9
|
+
"""Tests for authentication configuration."""
|
|
10
|
+
|
|
11
|
+
def test_auth_disabled_by_default(self):
|
|
12
|
+
"""Test that auth is disabled by default."""
|
|
13
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
14
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
15
|
+
config = get_auth_config()
|
|
16
|
+
assert config.enabled is False
|
|
17
|
+
|
|
18
|
+
def test_auth_enabled_via_env(self):
|
|
19
|
+
"""Test enabling auth via environment variable."""
|
|
20
|
+
with patch.dict(os.environ, {"MCP_AUTH_ENABLED": "true"}, clear=True):
|
|
21
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
22
|
+
config = get_auth_config()
|
|
23
|
+
assert config.enabled is True
|
|
24
|
+
|
|
25
|
+
def test_auth_issuer_from_env(self):
|
|
26
|
+
"""Test issuer URL from environment."""
|
|
27
|
+
with patch.dict(os.environ, {
|
|
28
|
+
"MCP_AUTH_ENABLED": "true",
|
|
29
|
+
"MCP_AUTH_ISSUER": "https://auth.example.com"
|
|
30
|
+
}, clear=True):
|
|
31
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
32
|
+
config = get_auth_config()
|
|
33
|
+
assert config.issuer_url == "https://auth.example.com"
|
|
34
|
+
|
|
35
|
+
def test_auth_audience_default(self):
|
|
36
|
+
"""Test default audience value."""
|
|
37
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
38
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
39
|
+
config = get_auth_config()
|
|
40
|
+
assert config.audience == "kubectl-mcp-server"
|
|
41
|
+
|
|
42
|
+
def test_auth_audience_custom(self):
|
|
43
|
+
"""Test custom audience value."""
|
|
44
|
+
with patch.dict(os.environ, {
|
|
45
|
+
"MCP_AUTH_AUDIENCE": "custom-audience"
|
|
46
|
+
}, clear=True):
|
|
47
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
48
|
+
config = get_auth_config()
|
|
49
|
+
assert config.audience == "custom-audience"
|
|
50
|
+
|
|
51
|
+
def test_auth_required_scopes_default(self):
|
|
52
|
+
"""Test default required scopes."""
|
|
53
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
54
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
55
|
+
config = get_auth_config()
|
|
56
|
+
assert "mcp:tools" in config.required_scopes
|
|
57
|
+
|
|
58
|
+
def test_auth_required_scopes_custom(self):
|
|
59
|
+
"""Test custom required scopes."""
|
|
60
|
+
with patch.dict(os.environ, {
|
|
61
|
+
"MCP_AUTH_REQUIRED_SCOPES": "mcp:read,mcp:write"
|
|
62
|
+
}, clear=True):
|
|
63
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
64
|
+
config = get_auth_config()
|
|
65
|
+
assert "mcp:read" in config.required_scopes
|
|
66
|
+
assert "mcp:write" in config.required_scopes
|
|
67
|
+
|
|
68
|
+
def test_effective_jwks_uri_derived(self):
|
|
69
|
+
"""Test JWKS URI is derived from issuer."""
|
|
70
|
+
with patch.dict(os.environ, {
|
|
71
|
+
"MCP_AUTH_ISSUER": "https://auth.example.com"
|
|
72
|
+
}, clear=True):
|
|
73
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
74
|
+
config = get_auth_config()
|
|
75
|
+
assert config.effective_jwks_uri == "https://auth.example.com/.well-known/jwks.json"
|
|
76
|
+
|
|
77
|
+
def test_effective_jwks_uri_explicit(self):
|
|
78
|
+
"""Test explicit JWKS URI takes precedence."""
|
|
79
|
+
with patch.dict(os.environ, {
|
|
80
|
+
"MCP_AUTH_ISSUER": "https://auth.example.com",
|
|
81
|
+
"MCP_AUTH_JWKS_URI": "https://custom.example.com/jwks"
|
|
82
|
+
}, clear=True):
|
|
83
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
84
|
+
config = get_auth_config()
|
|
85
|
+
assert config.effective_jwks_uri == "https://custom.example.com/jwks"
|
|
86
|
+
|
|
87
|
+
def test_config_validation_disabled(self):
|
|
88
|
+
"""Test validation passes when auth is disabled."""
|
|
89
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
90
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
91
|
+
config = get_auth_config()
|
|
92
|
+
assert config.validate() is True
|
|
93
|
+
|
|
94
|
+
def test_config_validation_enabled_without_issuer(self):
|
|
95
|
+
"""Test validation fails when enabled without issuer."""
|
|
96
|
+
with patch.dict(os.environ, {
|
|
97
|
+
"MCP_AUTH_ENABLED": "true"
|
|
98
|
+
}, clear=True):
|
|
99
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
100
|
+
config = get_auth_config()
|
|
101
|
+
assert config.validate() is False
|
|
102
|
+
|
|
103
|
+
def test_config_validation_enabled_with_issuer(self):
|
|
104
|
+
"""Test validation passes when properly configured."""
|
|
105
|
+
with patch.dict(os.environ, {
|
|
106
|
+
"MCP_AUTH_ENABLED": "true",
|
|
107
|
+
"MCP_AUTH_ISSUER": "https://auth.example.com"
|
|
108
|
+
}, clear=True):
|
|
109
|
+
from kubectl_mcp_tool.auth.config import get_auth_config
|
|
110
|
+
config = get_auth_config()
|
|
111
|
+
assert config.validate() is True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestMCPScopes:
|
|
115
|
+
"""Tests for MCP scope definitions."""
|
|
116
|
+
|
|
117
|
+
def test_all_scopes_returns_list(self):
|
|
118
|
+
"""Test all_scopes returns a list."""
|
|
119
|
+
from kubectl_mcp_tool.auth.scopes import MCPScopes
|
|
120
|
+
scopes = MCPScopes.all_scopes()
|
|
121
|
+
assert isinstance(scopes, list)
|
|
122
|
+
assert len(scopes) > 0
|
|
123
|
+
|
|
124
|
+
def test_scope_values(self):
|
|
125
|
+
"""Test scope enum values."""
|
|
126
|
+
from kubectl_mcp_tool.auth.scopes import MCPScopes
|
|
127
|
+
assert MCPScopes.READ.value == "mcp:read"
|
|
128
|
+
assert MCPScopes.WRITE.value == "mcp:write"
|
|
129
|
+
assert MCPScopes.ADMIN.value == "mcp:admin"
|
|
130
|
+
assert MCPScopes.TOOLS.value == "mcp:tools"
|
|
131
|
+
assert MCPScopes.HELM.value == "mcp:helm"
|
|
132
|
+
|
|
133
|
+
def test_read_scopes(self):
|
|
134
|
+
"""Test read-only scopes."""
|
|
135
|
+
from kubectl_mcp_tool.auth.scopes import MCPScopes
|
|
136
|
+
scopes = MCPScopes.read_scopes()
|
|
137
|
+
assert "mcp:read" in scopes
|
|
138
|
+
assert "mcp:diagnostics" in scopes
|
|
139
|
+
|
|
140
|
+
def test_admin_scopes_includes_all(self):
|
|
141
|
+
"""Test admin scopes include all scopes."""
|
|
142
|
+
from kubectl_mcp_tool.auth.scopes import MCPScopes
|
|
143
|
+
admin_scopes = MCPScopes.admin_scopes()
|
|
144
|
+
all_scopes = MCPScopes.all_scopes()
|
|
145
|
+
assert set(admin_scopes) == set(all_scopes)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TestToolScopes:
|
|
149
|
+
"""Tests for tool-to-scope mappings."""
|
|
150
|
+
|
|
151
|
+
def test_get_required_scopes_read_tool(self):
|
|
152
|
+
"""Test read tools require read scope."""
|
|
153
|
+
from kubectl_mcp_tool.auth.scopes import get_required_scopes, MCPScopes
|
|
154
|
+
scopes = get_required_scopes("get_pods")
|
|
155
|
+
assert MCPScopes.READ.value in scopes
|
|
156
|
+
|
|
157
|
+
def test_get_required_scopes_write_tool(self):
|
|
158
|
+
"""Test write tools require write scope."""
|
|
159
|
+
from kubectl_mcp_tool.auth.scopes import get_required_scopes, MCPScopes
|
|
160
|
+
scopes = get_required_scopes("scale_deployment")
|
|
161
|
+
assert MCPScopes.WRITE.value in scopes
|
|
162
|
+
|
|
163
|
+
def test_get_required_scopes_admin_tool(self):
|
|
164
|
+
"""Test admin tools require admin scope."""
|
|
165
|
+
from kubectl_mcp_tool.auth.scopes import get_required_scopes, MCPScopes
|
|
166
|
+
scopes = get_required_scopes("drain_node")
|
|
167
|
+
assert MCPScopes.ADMIN.value in scopes
|
|
168
|
+
|
|
169
|
+
def test_get_required_scopes_helm_tool(self):
|
|
170
|
+
"""Test helm tools require helm scope."""
|
|
171
|
+
from kubectl_mcp_tool.auth.scopes import get_required_scopes, MCPScopes
|
|
172
|
+
scopes = get_required_scopes("helm_list_releases")
|
|
173
|
+
assert MCPScopes.HELM.value in scopes
|
|
174
|
+
|
|
175
|
+
def test_get_required_scopes_unknown_tool(self):
|
|
176
|
+
"""Test unknown tools require default scope."""
|
|
177
|
+
from kubectl_mcp_tool.auth.scopes import get_required_scopes, MCPScopes
|
|
178
|
+
scopes = get_required_scopes("unknown_tool_xyz")
|
|
179
|
+
assert MCPScopes.TOOLS.value in scopes
|
|
180
|
+
|
|
181
|
+
def test_has_required_scopes_with_tools_scope(self):
|
|
182
|
+
"""Test mcp:tools grants access to all tools."""
|
|
183
|
+
from kubectl_mcp_tool.auth.scopes import has_required_scopes, MCPScopes
|
|
184
|
+
token_scopes = {MCPScopes.TOOLS.value}
|
|
185
|
+
assert has_required_scopes(token_scopes, "get_pods") is True
|
|
186
|
+
assert has_required_scopes(token_scopes, "drain_node") is True
|
|
187
|
+
|
|
188
|
+
def test_has_required_scopes_with_admin_scope(self):
|
|
189
|
+
"""Test mcp:admin grants access to all tools."""
|
|
190
|
+
from kubectl_mcp_tool.auth.scopes import has_required_scopes, MCPScopes
|
|
191
|
+
token_scopes = {MCPScopes.ADMIN.value}
|
|
192
|
+
assert has_required_scopes(token_scopes, "get_pods") is True
|
|
193
|
+
assert has_required_scopes(token_scopes, "drain_node") is True
|
|
194
|
+
|
|
195
|
+
def test_has_required_scopes_read_only(self):
|
|
196
|
+
"""Test read scope grants access to read tools only."""
|
|
197
|
+
from kubectl_mcp_tool.auth.scopes import has_required_scopes, MCPScopes
|
|
198
|
+
token_scopes = {MCPScopes.READ.value}
|
|
199
|
+
assert has_required_scopes(token_scopes, "get_pods") is True
|
|
200
|
+
assert has_required_scopes(token_scopes, "scale_deployment") is False
|
|
201
|
+
|
|
202
|
+
def test_has_required_scopes_write_not_admin(self):
|
|
203
|
+
"""Test write scope doesn't grant admin access."""
|
|
204
|
+
from kubectl_mcp_tool.auth.scopes import has_required_scopes, MCPScopes
|
|
205
|
+
token_scopes = {MCPScopes.WRITE.value}
|
|
206
|
+
assert has_required_scopes(token_scopes, "scale_deployment") is True
|
|
207
|
+
assert has_required_scopes(token_scopes, "drain_node") is False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TestAuthVerifier:
|
|
211
|
+
"""Tests for authentication verifier creation."""
|
|
212
|
+
|
|
213
|
+
def test_create_verifier_auth_disabled(self):
|
|
214
|
+
"""Test verifier is None when auth disabled."""
|
|
215
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
216
|
+
from kubectl_mcp_tool.auth import get_auth_config, create_auth_verifier
|
|
217
|
+
config = get_auth_config()
|
|
218
|
+
verifier = create_auth_verifier(config)
|
|
219
|
+
assert verifier is None
|
|
220
|
+
|
|
221
|
+
def test_create_verifier_missing_fastmcp_auth(self):
|
|
222
|
+
"""Test graceful handling when FastMCP auth not available."""
|
|
223
|
+
with patch.dict(os.environ, {
|
|
224
|
+
"MCP_AUTH_ENABLED": "true",
|
|
225
|
+
"MCP_AUTH_ISSUER": "https://auth.example.com"
|
|
226
|
+
}, clear=True):
|
|
227
|
+
from kubectl_mcp_tool.auth import get_auth_config, create_auth_verifier
|
|
228
|
+
config = get_auth_config()
|
|
229
|
+
|
|
230
|
+
# Mock the import to fail
|
|
231
|
+
with patch.dict('sys.modules', {'fastmcp.server.auth': None}):
|
|
232
|
+
verifier = create_auth_verifier(config)
|
|
233
|
+
# Should return None gracefully when auth module not available
|
|
234
|
+
assert verifier is None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestMCPServerAuth:
|
|
238
|
+
"""Tests for MCP server with authentication."""
|
|
239
|
+
|
|
240
|
+
def test_server_initializes_without_auth(self):
|
|
241
|
+
"""Test server initializes with auth disabled."""
|
|
242
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
243
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
244
|
+
server = MCPServer(name="test-server")
|
|
245
|
+
assert server.auth_config.enabled is False
|
|
246
|
+
|
|
247
|
+
def test_server_with_auth_enabled(self):
|
|
248
|
+
"""Test server with auth enabled loads config."""
|
|
249
|
+
with patch.dict(os.environ, {
|
|
250
|
+
"MCP_AUTH_ENABLED": "true",
|
|
251
|
+
"MCP_AUTH_ISSUER": "https://auth.example.com"
|
|
252
|
+
}, clear=True):
|
|
253
|
+
from kubectl_mcp_tool.mcp_server import MCPServer
|
|
254
|
+
server = MCPServer(name="test-server")
|
|
255
|
+
assert server.auth_config.enabled is True
|
|
256
|
+
assert server.auth_config.issuer_url == "https://auth.example.com"
|