kubectl-mcp-server 1.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kubectl_mcp_server-1.12.0.dist-info/METADATA +711 -0
- kubectl_mcp_server-1.12.0.dist-info/RECORD +45 -0
- kubectl_mcp_server-1.12.0.dist-info/WHEEL +5 -0
- kubectl_mcp_server-1.12.0.dist-info/entry_points.txt +3 -0
- kubectl_mcp_server-1.12.0.dist-info/licenses/LICENSE +21 -0
- kubectl_mcp_server-1.12.0.dist-info/top_level.txt +2 -0
- kubectl_mcp_tool/__init__.py +21 -0
- kubectl_mcp_tool/__main__.py +46 -0
- kubectl_mcp_tool/auth/__init__.py +13 -0
- kubectl_mcp_tool/auth/config.py +71 -0
- kubectl_mcp_tool/auth/scopes.py +148 -0
- kubectl_mcp_tool/auth/verifier.py +82 -0
- kubectl_mcp_tool/cli/__init__.py +9 -0
- kubectl_mcp_tool/cli/__main__.py +10 -0
- kubectl_mcp_tool/cli/cli.py +111 -0
- kubectl_mcp_tool/diagnostics.py +355 -0
- kubectl_mcp_tool/k8s_config.py +289 -0
- kubectl_mcp_tool/mcp_server.py +530 -0
- kubectl_mcp_tool/prompts/__init__.py +5 -0
- kubectl_mcp_tool/prompts/prompts.py +823 -0
- kubectl_mcp_tool/resources/__init__.py +5 -0
- kubectl_mcp_tool/resources/resources.py +305 -0
- kubectl_mcp_tool/tools/__init__.py +28 -0
- kubectl_mcp_tool/tools/browser.py +371 -0
- kubectl_mcp_tool/tools/cluster.py +315 -0
- kubectl_mcp_tool/tools/core.py +421 -0
- kubectl_mcp_tool/tools/cost.py +680 -0
- kubectl_mcp_tool/tools/deployments.py +381 -0
- kubectl_mcp_tool/tools/diagnostics.py +174 -0
- kubectl_mcp_tool/tools/helm.py +1561 -0
- kubectl_mcp_tool/tools/networking.py +296 -0
- kubectl_mcp_tool/tools/operations.py +501 -0
- kubectl_mcp_tool/tools/pods.py +582 -0
- kubectl_mcp_tool/tools/security.py +333 -0
- kubectl_mcp_tool/tools/storage.py +133 -0
- kubectl_mcp_tool/utils/__init__.py +17 -0
- kubectl_mcp_tool/utils/helpers.py +80 -0
- tests/__init__.py +9 -0
- tests/conftest.py +379 -0
- tests/test_auth.py +256 -0
- tests/test_browser.py +349 -0
- tests/test_prompts.py +536 -0
- tests/test_resources.py +343 -0
- tests/test_server.py +384 -0
- tests/test_tools.py +659 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP server implementation for kubectl-mcp-tool.
|
|
4
|
+
|
|
5
|
+
Compatible with:
|
|
6
|
+
- Claude Desktop
|
|
7
|
+
- Cursor AI
|
|
8
|
+
- Windsurf
|
|
9
|
+
- Docker MCP Toolkit (https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/)
|
|
10
|
+
|
|
11
|
+
FastMCP Migration Notes:
|
|
12
|
+
------------------------
|
|
13
|
+
Currently using: fastmcp (gofastmcp.com) - standalone package with extra features
|
|
14
|
+
To revert to official Anthropic MCP SDK:
|
|
15
|
+
1. Change requirements.txt: fastmcp>=3.0.0 -> mcp>=1.8.0
|
|
16
|
+
2. Change import below: from fastmcp import FastMCP -> from mcp.server.fastmcp import FastMCP
|
|
17
|
+
3. Change ToolAnnotations import: from fastmcp.tools import ToolAnnotations -> from mcp.types import ToolAnnotations
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import logging
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
from typing import List, Optional, Any
|
|
27
|
+
|
|
28
|
+
# Import k8s_config early to patch kubernetes config for in-cluster support
|
|
29
|
+
# This must be done before any tools are imported
|
|
30
|
+
import kubectl_mcp_tool.k8s_config # noqa: F401
|
|
31
|
+
|
|
32
|
+
from kubectl_mcp_tool.tools import (
|
|
33
|
+
register_helm_tools,
|
|
34
|
+
register_pod_tools,
|
|
35
|
+
register_core_tools,
|
|
36
|
+
register_cluster_tools,
|
|
37
|
+
register_deployment_tools,
|
|
38
|
+
register_security_tools,
|
|
39
|
+
register_networking_tools,
|
|
40
|
+
register_storage_tools,
|
|
41
|
+
register_operations_tools,
|
|
42
|
+
register_diagnostics_tools,
|
|
43
|
+
register_cost_tools,
|
|
44
|
+
register_browser_tools,
|
|
45
|
+
is_browser_available,
|
|
46
|
+
)
|
|
47
|
+
from kubectl_mcp_tool.resources import register_resources
|
|
48
|
+
from kubectl_mcp_tool.prompts import register_prompts
|
|
49
|
+
from kubectl_mcp_tool.auth import get_auth_config, create_auth_verifier
|
|
50
|
+
|
|
51
|
+
if platform.system() == "Windows":
|
|
52
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
53
|
+
|
|
54
|
+
import warnings
|
|
55
|
+
warnings.filterwarnings(
|
|
56
|
+
"ignore",
|
|
57
|
+
category=RuntimeWarning,
|
|
58
|
+
message=r".*found in sys.modules after import of package.*"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_log_file = os.environ.get("MCP_LOG_FILE")
|
|
62
|
+
_log_level = logging.DEBUG if os.environ.get("MCP_DEBUG", "").lower() in ("1", "true") else logging.INFO
|
|
63
|
+
|
|
64
|
+
_handlers: List[logging.Handler] = []
|
|
65
|
+
if _log_file:
|
|
66
|
+
try:
|
|
67
|
+
os.makedirs(os.path.dirname(_log_file), exist_ok=True)
|
|
68
|
+
_handlers.append(logging.FileHandler(_log_file))
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
_handlers.append(logging.StreamHandler(sys.stderr))
|
|
71
|
+
else:
|
|
72
|
+
_handlers.append(logging.StreamHandler(sys.stderr))
|
|
73
|
+
|
|
74
|
+
logging.basicConfig(
|
|
75
|
+
level=_log_level,
|
|
76
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
77
|
+
handlers=_handlers
|
|
78
|
+
)
|
|
79
|
+
logger = logging.getLogger("mcp-server")
|
|
80
|
+
|
|
81
|
+
for handler in logging.root.handlers[:]:
|
|
82
|
+
if isinstance(handler, logging.StreamHandler) and handler.stream == sys.stdout:
|
|
83
|
+
logging.root.removeHandler(handler)
|
|
84
|
+
|
|
85
|
+
# FastMCP 3 from gofastmcp.com (standalone package)
|
|
86
|
+
# To revert to official SDK: from mcp.server.fastmcp import FastMCP
|
|
87
|
+
try:
|
|
88
|
+
from fastmcp import FastMCP
|
|
89
|
+
except ImportError:
|
|
90
|
+
logger.error("FastMCP not found. Installing...")
|
|
91
|
+
import subprocess
|
|
92
|
+
try:
|
|
93
|
+
subprocess.check_call(
|
|
94
|
+
[sys.executable, "-m", "pip", "install", "fastmcp>=3.0.0b1"],
|
|
95
|
+
stdout=subprocess.DEVNULL,
|
|
96
|
+
stderr=subprocess.DEVNULL
|
|
97
|
+
)
|
|
98
|
+
from fastmcp import FastMCP
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.error(f"Failed to install FastMCP: {e}")
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MCPServer:
|
|
105
|
+
"""MCP server implementation."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, name: str, non_destructive: bool = False):
|
|
108
|
+
"""Initialize the MCP server.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
name: Server name for identification
|
|
112
|
+
non_destructive: If True, block destructive operations
|
|
113
|
+
|
|
114
|
+
Environment Variables:
|
|
115
|
+
MCP_AUTH_ENABLED: Enable OAuth 2.1 authentication (default: false)
|
|
116
|
+
MCP_AUTH_ISSUER: OAuth 2.0 Authorization Server URL
|
|
117
|
+
MCP_AUTH_JWKS_URI: JWKS endpoint (optional, derived from issuer)
|
|
118
|
+
MCP_AUTH_AUDIENCE: Expected token audience (default: kubectl-mcp-server)
|
|
119
|
+
MCP_AUTH_REQUIRED_SCOPES: Required scopes (default: mcp:tools)
|
|
120
|
+
"""
|
|
121
|
+
self.name = name
|
|
122
|
+
self.non_destructive = non_destructive
|
|
123
|
+
self._dependencies_checked = False
|
|
124
|
+
self._dependencies_available = None
|
|
125
|
+
|
|
126
|
+
# Load authentication configuration
|
|
127
|
+
self.auth_config = get_auth_config()
|
|
128
|
+
auth_verifier = self._setup_auth()
|
|
129
|
+
|
|
130
|
+
# Initialize FastMCP with optional authentication
|
|
131
|
+
if auth_verifier:
|
|
132
|
+
logger.info("Initializing MCP server with authentication enabled")
|
|
133
|
+
self.server = FastMCP(name=name, auth=auth_verifier)
|
|
134
|
+
else:
|
|
135
|
+
self.server = FastMCP(name=name)
|
|
136
|
+
|
|
137
|
+
self.setup_tools()
|
|
138
|
+
self.setup_resources()
|
|
139
|
+
self.setup_prompts()
|
|
140
|
+
|
|
141
|
+
def _setup_auth(self) -> Optional[Any]:
|
|
142
|
+
"""Set up authentication if enabled."""
|
|
143
|
+
if not self.auth_config.enabled:
|
|
144
|
+
logger.debug("Authentication disabled")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
verifier = create_auth_verifier(self.auth_config)
|
|
149
|
+
if verifier:
|
|
150
|
+
logger.info(f"Authentication configured with issuer: {self.auth_config.issuer_url}")
|
|
151
|
+
return verifier
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Failed to configure authentication: {e}")
|
|
154
|
+
if self.auth_config.enabled:
|
|
155
|
+
raise
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def dependencies_available(self) -> bool:
|
|
160
|
+
"""Lazy check for dependencies (only runs once, on first access)."""
|
|
161
|
+
if not self._dependencies_checked:
|
|
162
|
+
self._dependencies_available = self._check_dependencies()
|
|
163
|
+
self._dependencies_checked = True
|
|
164
|
+
if not self._dependencies_available:
|
|
165
|
+
logger.warning("Some dependencies are missing. Certain operations may not work correctly.")
|
|
166
|
+
return self._dependencies_available
|
|
167
|
+
|
|
168
|
+
def setup_tools(self):
|
|
169
|
+
"""Set up the tools for the MCP server by calling all registration functions."""
|
|
170
|
+
# Register all tool modules
|
|
171
|
+
register_helm_tools(self.server, self.non_destructive, self._check_helm_availability)
|
|
172
|
+
register_pod_tools(self.server, self.non_destructive)
|
|
173
|
+
register_core_tools(self.server, self.non_destructive)
|
|
174
|
+
register_cluster_tools(self.server, self.non_destructive)
|
|
175
|
+
register_deployment_tools(self.server, self.non_destructive)
|
|
176
|
+
register_security_tools(self.server, self.non_destructive)
|
|
177
|
+
register_networking_tools(self.server, self.non_destructive)
|
|
178
|
+
register_storage_tools(self.server, self.non_destructive)
|
|
179
|
+
register_operations_tools(self.server, self.non_destructive)
|
|
180
|
+
register_diagnostics_tools(self.server, self.non_destructive)
|
|
181
|
+
register_cost_tools(self.server, self.non_destructive)
|
|
182
|
+
|
|
183
|
+
# Register optional browser tools if enabled and available
|
|
184
|
+
if is_browser_available():
|
|
185
|
+
register_browser_tools(self.server, self.non_destructive)
|
|
186
|
+
logger.info("Browser automation tools enabled (MCP_BROWSER_ENABLED=true)")
|
|
187
|
+
else:
|
|
188
|
+
logger.debug("Browser tools disabled (set MCP_BROWSER_ENABLED=true to enable)")
|
|
189
|
+
|
|
190
|
+
def setup_resources(self):
|
|
191
|
+
"""Set up MCP resources for Kubernetes data exposure."""
|
|
192
|
+
register_resources(self.server)
|
|
193
|
+
|
|
194
|
+
def setup_prompts(self):
|
|
195
|
+
"""Set up MCP prompts."""
|
|
196
|
+
register_prompts(self.server)
|
|
197
|
+
|
|
198
|
+
def _check_dependencies(self) -> bool:
|
|
199
|
+
"""Check if required dependencies are available."""
|
|
200
|
+
kubectl_ok = self._check_kubectl_availability()
|
|
201
|
+
if not kubectl_ok:
|
|
202
|
+
logger.warning("kubectl is not available in PATH")
|
|
203
|
+
return kubectl_ok
|
|
204
|
+
|
|
205
|
+
def _check_tool_availability(self, tool: str) -> bool:
|
|
206
|
+
"""Check if a tool (kubectl, helm) is available and working."""
|
|
207
|
+
try:
|
|
208
|
+
import subprocess
|
|
209
|
+
import shutil
|
|
210
|
+
if shutil.which(tool) is None:
|
|
211
|
+
return False
|
|
212
|
+
if tool == "kubectl":
|
|
213
|
+
subprocess.check_output(
|
|
214
|
+
[tool, "version", "--client", "--output=json"],
|
|
215
|
+
stderr=subprocess.PIPE,
|
|
216
|
+
timeout=2
|
|
217
|
+
)
|
|
218
|
+
elif tool == "helm":
|
|
219
|
+
subprocess.check_output(
|
|
220
|
+
[tool, "version", "--short"],
|
|
221
|
+
stderr=subprocess.PIPE,
|
|
222
|
+
timeout=2
|
|
223
|
+
)
|
|
224
|
+
return True
|
|
225
|
+
except (subprocess.SubprocessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
def _check_kubectl_availability(self) -> bool:
|
|
229
|
+
"""Check if kubectl is available."""
|
|
230
|
+
return self._check_tool_availability("kubectl")
|
|
231
|
+
|
|
232
|
+
def _check_helm_availability(self) -> bool:
|
|
233
|
+
"""Check if helm is available."""
|
|
234
|
+
return self._check_tool_availability("helm")
|
|
235
|
+
|
|
236
|
+
def _check_destructive(self):
|
|
237
|
+
"""Check if destructive operations are allowed.
|
|
238
|
+
|
|
239
|
+
Returns None if allowed, error dict if blocked.
|
|
240
|
+
"""
|
|
241
|
+
if self.non_destructive:
|
|
242
|
+
return {"success": False, "error": "Operation blocked: non-destructive mode enabled"}
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def _mask_secrets(self, text: str) -> str:
|
|
246
|
+
"""Mask sensitive data in text output.
|
|
247
|
+
|
|
248
|
+
Masks base64-encoded secrets, passwords, tokens, and API keys.
|
|
249
|
+
"""
|
|
250
|
+
import re
|
|
251
|
+
|
|
252
|
+
# Mask base64-encoded data (common in Kubernetes secrets)
|
|
253
|
+
# Match data fields with base64 values (at least 16 chars)
|
|
254
|
+
text = re.sub(
|
|
255
|
+
r'(data:\s*\n(?:\s+\w+:\s*)[A-Za-z0-9+/=]{16,})',
|
|
256
|
+
lambda m: re.sub(r':\s*[A-Za-z0-9+/=]{16,}', ': [MASKED]', m.group(0)),
|
|
257
|
+
text
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Mask password fields
|
|
261
|
+
text = re.sub(
|
|
262
|
+
r'(password|passwd|secret|credential)(\s*[=:]\s*)["\']?[^"\'\s]+["\']?',
|
|
263
|
+
r'\1\2[MASKED]',
|
|
264
|
+
text,
|
|
265
|
+
flags=re.IGNORECASE
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Mask token fields
|
|
269
|
+
text = re.sub(
|
|
270
|
+
r'(token|api[_-]?key|auth[_-]?key)(\s*[=:]\s*)["\']?[^"\'\s]+["\']?',
|
|
271
|
+
r'\1\2[MASKED]',
|
|
272
|
+
text,
|
|
273
|
+
flags=re.IGNORECASE
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Mask Bearer tokens
|
|
277
|
+
text = re.sub(
|
|
278
|
+
r'(Bearer\s+)[A-Za-z0-9._-]+',
|
|
279
|
+
r'\1[MASKED]',
|
|
280
|
+
text
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Mask JWT tokens (three base64 sections separated by dots)
|
|
284
|
+
text = re.sub(
|
|
285
|
+
r'eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*',
|
|
286
|
+
'[MASKED]',
|
|
287
|
+
text
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return text
|
|
291
|
+
|
|
292
|
+
async def serve_stdio(self):
|
|
293
|
+
"""Serve the MCP server over stdio transport."""
|
|
294
|
+
if os.environ.get("MCP_DEBUG", "").lower() in ("1", "true"):
|
|
295
|
+
logger.debug("Starting MCP server with stdio transport")
|
|
296
|
+
logger.debug(f"Working directory: {os.getcwd()}")
|
|
297
|
+
kube_config = os.environ.get('KUBECONFIG', '~/.kube/config')
|
|
298
|
+
expanded_path = os.path.expanduser(kube_config)
|
|
299
|
+
logger.debug(f"KUBECONFIG: {expanded_path}")
|
|
300
|
+
logger.debug(f"Dependencies: {'available' if self.dependencies_available else 'missing'}")
|
|
301
|
+
|
|
302
|
+
await self.server.run_stdio_async()
|
|
303
|
+
|
|
304
|
+
async def serve_sse(self, host: str = "0.0.0.0", port: int = 8000):
|
|
305
|
+
"""Serve the MCP server over SSE transport.
|
|
306
|
+
|
|
307
|
+
Uses FastMCP 3's create_sse_app() to create a Starlette ASGI application
|
|
308
|
+
that handles Server-Sent Events for MCP communication.
|
|
309
|
+
|
|
310
|
+
SSE Endpoints:
|
|
311
|
+
- GET /sse: SSE connection endpoint for receiving server events
|
|
312
|
+
- POST /messages/: Endpoint for sending messages to the server
|
|
313
|
+
"""
|
|
314
|
+
logger.info(f"Starting MCP server with SSE transport on {host}:{port}")
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
import uvicorn
|
|
318
|
+
except ImportError:
|
|
319
|
+
logger.error("SSE transport requires 'uvicorn'. Install with: pip install uvicorn")
|
|
320
|
+
raise ImportError("Missing dependency for SSE transport. Run: pip install uvicorn")
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
# FastMCP 3 uses create_sse_app() to create a Starlette ASGI app
|
|
324
|
+
from fastmcp.server.http import create_sse_app
|
|
325
|
+
|
|
326
|
+
# Create the SSE Starlette application
|
|
327
|
+
# message_path: POST endpoint for client messages
|
|
328
|
+
# sse_path: GET endpoint for SSE event stream
|
|
329
|
+
app = create_sse_app(
|
|
330
|
+
self.server,
|
|
331
|
+
message_path="/messages/",
|
|
332
|
+
sse_path="/sse"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
logger.info(f"SSE endpoints: GET /sse (events), POST /messages/ (messages)")
|
|
336
|
+
|
|
337
|
+
# Run with uvicorn
|
|
338
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
|
339
|
+
server = uvicorn.Server(config)
|
|
340
|
+
await server.serve()
|
|
341
|
+
|
|
342
|
+
except ImportError as e:
|
|
343
|
+
# Fallback for older FastMCP versions that might have run_sse_async
|
|
344
|
+
logger.warning(f"create_sse_app not available: {e}. Trying legacy API...")
|
|
345
|
+
try:
|
|
346
|
+
await self.server.run_sse_async(host=host, port=port)
|
|
347
|
+
except (TypeError, AttributeError):
|
|
348
|
+
try:
|
|
349
|
+
await self.server.run_sse_async(port=port)
|
|
350
|
+
except (TypeError, AttributeError):
|
|
351
|
+
await self.server.run_sse_async()
|
|
352
|
+
|
|
353
|
+
async def serve_http(self, host: str = "0.0.0.0", port: int = 8000):
|
|
354
|
+
"""
|
|
355
|
+
Serve the MCP server over HTTP transport (streamable HTTP).
|
|
356
|
+
This is an alternative to SSE that some clients prefer.
|
|
357
|
+
"""
|
|
358
|
+
logger.info(f"Starting MCP server with HTTP transport on {host}:{port}")
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
# Check if FastMCP supports streamable HTTP
|
|
362
|
+
if hasattr(self.server, 'run_http_async'):
|
|
363
|
+
await self.server.run_http_async(host=host, port=port)
|
|
364
|
+
elif hasattr(self.server, 'run_streamable_http_async'):
|
|
365
|
+
await self.server.run_streamable_http_async(host=host, port=port)
|
|
366
|
+
else:
|
|
367
|
+
# Fall back to implementing HTTP transport manually using ASGI
|
|
368
|
+
logger.info("FastMCP does not have built-in HTTP support, using custom implementation")
|
|
369
|
+
await self._serve_http_custom(host=host, port=port)
|
|
370
|
+
except TypeError as e:
|
|
371
|
+
logger.warning(f"HTTP transport parameter issue: {e}. Trying alternative signatures...")
|
|
372
|
+
# Try without parameters
|
|
373
|
+
if hasattr(self.server, 'run_http_async'):
|
|
374
|
+
await self.server.run_http_async()
|
|
375
|
+
elif hasattr(self.server, 'run_streamable_http_async'):
|
|
376
|
+
await self.server.run_streamable_http_async()
|
|
377
|
+
else:
|
|
378
|
+
await self._serve_http_custom(host=host, port=port)
|
|
379
|
+
|
|
380
|
+
async def _serve_http_custom(self, host: str = "0.0.0.0", port: int = 8000):
|
|
381
|
+
"""
|
|
382
|
+
Custom HTTP server implementation using uvicorn and Starlette.
|
|
383
|
+
Provides HTTP/JSON-RPC transport for MCP.
|
|
384
|
+
"""
|
|
385
|
+
try:
|
|
386
|
+
from starlette.applications import Starlette
|
|
387
|
+
from starlette.responses import JSONResponse
|
|
388
|
+
from starlette.routing import Route
|
|
389
|
+
import uvicorn
|
|
390
|
+
except ImportError:
|
|
391
|
+
logger.error("HTTP transport requires 'starlette' and 'uvicorn'. Install with: pip install starlette uvicorn")
|
|
392
|
+
raise ImportError("Missing dependencies for HTTP transport. Run: pip install starlette uvicorn")
|
|
393
|
+
|
|
394
|
+
async def handle_mcp_request(request):
|
|
395
|
+
"""Handle incoming MCP JSON-RPC requests."""
|
|
396
|
+
try:
|
|
397
|
+
body = await request.json()
|
|
398
|
+
logger.debug(f"Received MCP request: {body}")
|
|
399
|
+
|
|
400
|
+
# Get the method and params from the JSON-RPC request
|
|
401
|
+
method = body.get("method", "")
|
|
402
|
+
params = body.get("params", {})
|
|
403
|
+
request_id = body.get("id")
|
|
404
|
+
|
|
405
|
+
# Handle different MCP methods
|
|
406
|
+
if method == "initialize":
|
|
407
|
+
result = {
|
|
408
|
+
"protocolVersion": "2024-11-05",
|
|
409
|
+
"capabilities": {
|
|
410
|
+
"tools": {"listChanged": True},
|
|
411
|
+
"resources": {"subscribe": False, "listChanged": True}
|
|
412
|
+
},
|
|
413
|
+
"serverInfo": {
|
|
414
|
+
"name": self.name,
|
|
415
|
+
"version": "1.2.0"
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
elif method == "tools/list":
|
|
419
|
+
# Get list of tools from FastMCP
|
|
420
|
+
tools = []
|
|
421
|
+
if hasattr(self.server, '_tool_manager') and hasattr(self.server._tool_manager, 'tools'):
|
|
422
|
+
for name, tool in self.server._tool_manager.tools.items():
|
|
423
|
+
tools.append({
|
|
424
|
+
"name": name,
|
|
425
|
+
"description": tool.description if hasattr(tool, 'description') else "",
|
|
426
|
+
"inputSchema": tool.parameters if hasattr(tool, 'parameters') else {}
|
|
427
|
+
})
|
|
428
|
+
result = {"tools": tools}
|
|
429
|
+
elif method == "tools/call":
|
|
430
|
+
tool_name = params.get("name", "")
|
|
431
|
+
tool_args = params.get("arguments", {})
|
|
432
|
+
|
|
433
|
+
# Execute the tool
|
|
434
|
+
if hasattr(self.server, '_tool_manager'):
|
|
435
|
+
try:
|
|
436
|
+
tool_result = await self.server._tool_manager.call_tool(tool_name, tool_args)
|
|
437
|
+
result = {"content": [{"type": "text", "text": json.dumps(tool_result)}]}
|
|
438
|
+
except Exception as e:
|
|
439
|
+
result = {"content": [{"type": "text", "text": f"Error: {str(e)}"}], "isError": True}
|
|
440
|
+
else:
|
|
441
|
+
result = {"content": [{"type": "text", "text": "Tool manager not available"}], "isError": True}
|
|
442
|
+
elif method == "ping":
|
|
443
|
+
result = {}
|
|
444
|
+
else:
|
|
445
|
+
result = {"error": f"Unknown method: {method}"}
|
|
446
|
+
|
|
447
|
+
response = {
|
|
448
|
+
"jsonrpc": "2.0",
|
|
449
|
+
"id": request_id,
|
|
450
|
+
"result": result
|
|
451
|
+
}
|
|
452
|
+
return JSONResponse(response)
|
|
453
|
+
except Exception as e:
|
|
454
|
+
logger.error(f"Error handling MCP request: {e}")
|
|
455
|
+
return JSONResponse({
|
|
456
|
+
"jsonrpc": "2.0",
|
|
457
|
+
"id": None,
|
|
458
|
+
"error": {"code": -32603, "message": str(e)}
|
|
459
|
+
}, status_code=500)
|
|
460
|
+
|
|
461
|
+
async def health_check(request):
|
|
462
|
+
"""Health check endpoint."""
|
|
463
|
+
return JSONResponse({"status": "healthy", "server": self.name})
|
|
464
|
+
|
|
465
|
+
app = Starlette(
|
|
466
|
+
routes=[
|
|
467
|
+
Route("/", handle_mcp_request, methods=["POST"]),
|
|
468
|
+
Route("/mcp", handle_mcp_request, methods=["POST"]),
|
|
469
|
+
Route("/health", health_check, methods=["GET"]),
|
|
470
|
+
]
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="info")
|
|
474
|
+
server = uvicorn.Server(config)
|
|
475
|
+
await server.serve()
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
import argparse
|
|
480
|
+
import signal
|
|
481
|
+
|
|
482
|
+
parser = argparse.ArgumentParser(description="Run the Kubectl MCP Server.")
|
|
483
|
+
parser.add_argument(
|
|
484
|
+
"--transport",
|
|
485
|
+
type=str,
|
|
486
|
+
choices=["stdio", "sse", "http", "streamable-http"],
|
|
487
|
+
default="stdio",
|
|
488
|
+
help="Communication transport to use (stdio, sse, http, or streamable-http). Default: stdio.",
|
|
489
|
+
)
|
|
490
|
+
parser.add_argument(
|
|
491
|
+
"--port",
|
|
492
|
+
type=int,
|
|
493
|
+
default=8000,
|
|
494
|
+
help="Port to use for SSE/HTTP transport. Default: 8000.",
|
|
495
|
+
)
|
|
496
|
+
parser.add_argument(
|
|
497
|
+
"--host",
|
|
498
|
+
type=str,
|
|
499
|
+
default="0.0.0.0",
|
|
500
|
+
help="Host to bind to for SSE/HTTP transport. Default: 0.0.0.0.",
|
|
501
|
+
)
|
|
502
|
+
args = parser.parse_args()
|
|
503
|
+
|
|
504
|
+
server_name = "kubectl_mcp_server"
|
|
505
|
+
mcp_server = MCPServer(name=server_name)
|
|
506
|
+
|
|
507
|
+
# Handle signals gracefully with immediate exit
|
|
508
|
+
def signal_handler(sig, frame):
|
|
509
|
+
print("\nShutting down server...", file=sys.stderr)
|
|
510
|
+
os._exit(0)
|
|
511
|
+
|
|
512
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
513
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
if args.transport == "stdio":
|
|
517
|
+
logger.info(f"Starting {server_name} with stdio transport.")
|
|
518
|
+
asyncio.run(mcp_server.serve_stdio())
|
|
519
|
+
elif args.transport == "sse":
|
|
520
|
+
logger.info(f"Starting {server_name} with SSE transport on {args.host}:{args.port}.")
|
|
521
|
+
asyncio.run(mcp_server.serve_sse(host=args.host, port=args.port))
|
|
522
|
+
elif args.transport in ("http", "streamable-http"):
|
|
523
|
+
logger.info(f"Starting {server_name} with HTTP transport on {args.host}:{args.port}.")
|
|
524
|
+
asyncio.run(mcp_server.serve_http(host=args.host, port=args.port))
|
|
525
|
+
except KeyboardInterrupt:
|
|
526
|
+
print("\nShutting down server...", file=sys.stderr)
|
|
527
|
+
except SystemExit:
|
|
528
|
+
pass # Clean exit
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.error(f"Server exited with error: {e}", exc_info=True)
|