Skip to content

Commit c01d29a

Browse files
committed
MCP for k8s with argo support
1 parent ad2ad2e commit c01d29a

File tree

2 files changed

+406
-0
lines changed

2 files changed

+406
-0
lines changed

argo-workflow/agents/argo-k8s.py

Lines changed: 384 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,384 @@
1+
from kubernetes import client, config
2+
3+
# from mcp.server.fastmcp import FastMCP │ 4 lines yanked │
4+
from fastmcp import FastMCP
5+
import logging
6+
import subprocess
7+
import os
8+
9+
10+
mcp = FastMCP("ArgoKubernetes MCP server")
11+
12+
# Configure logging
13+
logging.basicConfig(level=logging.INFO)
14+
logger = logging.getLogger(__name__)
15+
16+
# Global API clients that will be reinitialized when context changes
17+
coreV1 = None
18+
appsV1 = None
19+
batchV1 = None
20+
networkingV1 = None
21+
customObjectsApi = None
22+
23+
# Global variable to track current kubectl context
24+
# current_kubectl_context = "spendor.cluster"
25+
current_kubectl_context = "k3d-sunrise"
26+
current_namespace_context = "argo"
27+
28+
# Load initial kubeconfig (usually from ~/.kube/config)
29+
config.load_kube_config()
30+
31+
32+
def initialize_clients():
33+
"""Initialize or reinitialize all Kubernetes API clients."""
34+
# Create API clients
35+
global coreV1, appsV1, batchV1, networkingV1, customObjectsApi
36+
coreV1 = client.CoreV1Api()
37+
appsV1 = client.AppsV1Api()
38+
batchV1 = client.BatchV1Api()
39+
networkingV1 = client.NetworkingV1Api()
40+
customObjectsApi = client.CustomObjectsApi()
41+
logger.info("Kubernetes API clients initialized")
42+
43+
44+
def sc(context=current_kubectl_context):
45+
try:
46+
global current_kubectl_context
47+
48+
# First, switch the kubectl config context permanently
49+
subprocess.run(
50+
["kubectl", "config", "use-context", context],
51+
capture_output=True,
52+
text=True,
53+
check=True,
54+
)
55+
logger.info(f"kubectl config switched to: {context}")
56+
# Then load the kube config for Python client
57+
config.load_kube_config(context=context)
58+
initialize_clients() # Reinitialize clients with new context
59+
current_kubectl_context = context # Track the current context globally
60+
logger.info(f"Successfully switched to context: {context}")
61+
return {"message": f"Switched to context: {context}"}
62+
except subprocess.CalledProcessError as cmd_error:
63+
logger.error(f"Error switching kubectl context: {cmd_error.stderr}")
64+
return {"error": f"kubectl context switch failed: {cmd_error.stderr}"}
65+
except Exception as e:
66+
logger.error(f"Error switching context to {context}: {e}")
67+
return {"error": str(e)}
68+
69+
70+
def sn(namespace=current_namespace_context):
71+
try:
72+
global current_namespace_context
73+
# First, switch the kubectl config context permanently
74+
subprocess.run(
75+
["kubectl", "config", "set-context", "--current", "--namespace", namespace],
76+
capture_output=True,
77+
text=True,
78+
check=True,
79+
)
80+
logger.info(f"kubectl config switched to: {namespace}")
81+
current_namespace_context = namespace # Track the current context globally
82+
return {"message": f"Switched to namespace: {namespace}"}
83+
except subprocess.CalledProcessError as cmd_error:
84+
logger.error(f"Error switching kubectl context: {cmd_error.stderr}")
85+
return {"error": f"kubectl context switch failed: {cmd_error.stderr}"}
86+
except Exception as e:
87+
logger.error(f"Error switching namespace to {namespace}: {e}")
88+
return {"error": str(e)}
89+
90+
91+
@mcp.tool()
92+
def switch_context(context: str):
93+
"""
94+
Switch the active Kubernetes context to connect to a different cluster.
95+
96+
This tool changes the current Kubernetes context to the specified one, allowing
97+
you to switch between different clusters or namespaces. After switching, all
98+
subsequent kubectl commands and API calls will be directed to the new context.
99+
The Kubernetes API clients are automatically reinitialized for the new context.
100+
101+
Args:
102+
context (str): The name of the context to switch to. Must be a valid context
103+
name from your kubeconfig file. Use list_clusters() to see
104+
available contexts.
105+
106+
Returns:
107+
dict: A dictionary containing:
108+
- message: Success message if context switch was successful
109+
- error: Error message if the context switch failed
110+
111+
Example:
112+
Input: "dev-cluster"
113+
Returns: {"message": "Switched to context: dev-cluster"}
114+
"""
115+
sc(context)
116+
117+
118+
@mcp.tool()
119+
def switch_namespace(namespace: str):
120+
"""
121+
Switch the active Kubernetes namespace to connect to a different namespace.
122+
123+
This tool changes the current Kubernetes namespace to the specified one, allowing
124+
you to switch between different namespaces. After switching, all
125+
subsequent kubectl commands and API calls will be directed to the new namespace.
126+
The Kubernetes API clients are automatically reinitialized for the new namespace.
127+
128+
Args:
129+
namespace (str): The name of the namespace to switch to. Must be a valid namespace
130+
name from your cluster. Use list_namespaces() to see
131+
available namespaces.
132+
133+
Returns:
134+
dict: A dictionary containing:
135+
- message: Success message if namespace switch was successful
136+
- error: Error message if the namespace switch failed
137+
138+
Example:
139+
Input: "argo"
140+
Returns: {"message": "Switched to namespace: argo"}
141+
"""
142+
sn(namespace)
143+
144+
145+
@mcp.tool()
146+
def run_kubectl_command(command: str):
147+
"""
148+
Execute any kubectl command with full privileges (use with caution).
149+
150+
This tool allows execution of any kubectl command, including potentially
151+
destructive operations like delete, update, patch, apply, etc. It provides
152+
complete access to your Kubernetes cluster with the same permissions as
153+
your kubectl configuration.
154+
155+
WARNING: This tool can perform destructive operations. Use run_kubectl_command_ro()
156+
for safe, read-only operations when you only need to gather information.
157+
158+
Args:
159+
command (str): The complete kubectl command to execute. Must start with "kubectl".
160+
Examples: "kubectl delete pod nginx", "kubectl apply -f config.yaml"
161+
162+
Returns:
163+
str: The stdout output from the kubectl command, or an error message if the
164+
command fails or doesn't start with "kubectl".
165+
166+
Example:
167+
Input: "kubectl scale deployment nginx --replicas=3"
168+
Returns: "deployment.apps/nginx scaled"
169+
"""
170+
try:
171+
global current_kubectl_context
172+
173+
# Check if command starts with kubectl
174+
if not command.startswith("kubectl "):
175+
return "Error: Command must start with 'kubectl'"
176+
177+
# Add context flag if a context has been switched and --context is not already in command
178+
if current_kubectl_context and "--context" not in command:
179+
command_parts = command.split()
180+
command_parts.insert(1, f"--context={current_kubectl_context}")
181+
command = " ".join(command_parts)
182+
logger.info(
183+
f"Using context: {current_kubectl_context} for command: {command}"
184+
)
185+
186+
# Execute the full command as provided
187+
result = subprocess.run(
188+
command.split(), capture_output=True, text=True, check=True
189+
)
190+
return result.stdout
191+
except subprocess.CalledProcessError as e:
192+
logger.error(f"Error running kubectl command: {e.stderr}")
193+
return f"Error: {e.stderr}"
194+
195+
196+
@mcp.tool()
197+
def run_kubectl_command_ro(command: str):
198+
"""
199+
Execute read-only kubectl commands safely for information gathering.
200+
201+
This tool provides a safe way to run kubectl commands that only read information
202+
from your Kubernetes cluster without making any modifications. It blocks potentially
203+
destructive operations and only allows commands that gather information.
204+
205+
Allowed operations:
206+
- get: Retrieve resources (pods, deployments, services, etc.)
207+
- describe: Show detailed information about resources
208+
- explain: Show documentation for resource types
209+
- logs: Retrieve the logs of the resources (pods, deployments, services, etc.)
210+
- top: Display resource usage (cpu/memory)
211+
- config view: View kubeconfig settings
212+
- config get-contexts: List available contexts
213+
- version: Show kubectl and cluster version information
214+
- api-resources: List available API resources
215+
- cluster-info: Show cluster information
216+
- debug: debugs cluster resources using interactive containers
217+
- events: prints a table of the most important information about events.
218+
219+
Blocked operations include: delete, update, patch, apply, create, replace, edit,
220+
scale, cordon, drain, taint, and any command with --overwrite flags.
221+
222+
Args:
223+
command (str): The kubectl command to execute. Must start with "kubectl" and
224+
be a read-only operation. Examples: "kubectl get pods",
225+
"kubectl describe deployment nginx"
226+
227+
Returns:
228+
str: The stdout output from the kubectl command, or an error message if the
229+
command fails, doesn't start with "kubectl", or contains disallowed operations.
230+
231+
Example:
232+
Input: "kubectl get pods -n default"
233+
Returns: "NAME READY STATUS RESTARTS AGE\nnginx 1/1 Running 0 2d"
234+
"""
235+
try:
236+
global current_kubectl_context
237+
238+
# Check if command starts with kubectl
239+
if not command.startswith("kubectl "):
240+
return "Error: Command must start with 'kubectl'"
241+
242+
# Extract the actual kubectl subcommand (after "kubectl ")
243+
kubectl_subcommand = command[8:] # Remove "kubectl " prefix
244+
245+
# List of allowed command prefixes (read-only operations)
246+
allowed_prefixes = [
247+
"get",
248+
"describe",
249+
"explain",
250+
"logs",
251+
"top",
252+
"config view",
253+
"config get-contexts",
254+
"debug",
255+
"version",
256+
"api-resources",
257+
"cluster-info",
258+
"events",
259+
]
260+
261+
# List of disallowed terms that might modify resources
262+
disallowed_terms = [
263+
"delete",
264+
"update",
265+
"patch",
266+
"apply",
267+
"create",
268+
"replace",
269+
"edit",
270+
"scale",
271+
"cordon",
272+
"drain",
273+
"taint",
274+
"label --overwrite",
275+
"annotate --overwrite",
276+
]
277+
278+
# Check if command is allowed
279+
is_allowed = any(
280+
kubectl_subcommand.startswith(prefix) for prefix in allowed_prefixes
281+
)
282+
has_disallowed = any(term in kubectl_subcommand for term in disallowed_terms)
283+
284+
if not is_allowed or has_disallowed:
285+
return "Error: Only read-only kubectl commands are allowed (get, describe, etc.)"
286+
287+
# Add context flag if a context has been switched and --context is not already in command
288+
if current_kubectl_context and "--context" not in command:
289+
command_parts = command.split()
290+
command_parts.insert(1, f"--context={current_kubectl_context}")
291+
command = " ".join(command_parts)
292+
logger.info(f"Modified command with context: {command}")
293+
else:
294+
logger.info(
295+
f"No context modification needed. Context: {current_kubectl_context}, has --context: {'--context' in command}"
296+
)
297+
298+
# Execute the full command as provided
299+
logger.info(f"Executing command: {command}")
300+
result = subprocess.run(
301+
command.split(), capture_output=True, text=True, check=True
302+
)
303+
logger.info(
304+
f"Command successful, output length: {len(result.stdout)} characters"
305+
)
306+
return result.stdout
307+
except subprocess.CalledProcessError as e:
308+
logger.error(f"Error running kubectl command: {e.stderr}")
309+
return f"Error: {e.stderr}"
310+
except Exception as e:
311+
logger.error(f"Unexpected error in run_kubectl_command_ro: {e}")
312+
return f"Error: {e}"
313+
314+
315+
@mcp.tool()
316+
def run_argo_command(command: str):
317+
"""
318+
Execute any argo command with full privileges (use with caution).
319+
320+
This tool allows execution of any argo command, including potentially
321+
destructive operations like delete, stop, terminate, submit, etc. It provides
322+
complete access to your Argo server with the same permissions as
323+
your kubectl configuration.
324+
325+
Args:
326+
command (str): The complete argo command to execute. Must start with "argo".
327+
Examples: "argo submit -n argo --from clusterworkflowtemplate/automlai-dualsearch"
328+
329+
Returns:
330+
str: The stdout output from the argo command, or an error message if the
331+
command fails or doesn't start with "argo".
332+
333+
Example:
334+
Input: "argo -n argo list"
335+
Returns: "automl-dualsearch-pxd7s Succeeded 51d 49s 0"
336+
"""
337+
try:
338+
global current_kubectl_context, current_namespace_context
339+
340+
# Check if command starts with kubectl
341+
if not command.startswith("argo "):
342+
return "Error: Command must start with 'argo'"
343+
344+
# Add context flag if a context has been switched and --context is not already in command
345+
if current_kubectl_context and "--context" not in command:
346+
command_parts = command.split()
347+
command_parts.insert(1, f"--context={current_kubectl_context}")
348+
command = " ".join(command_parts)
349+
logger.info(
350+
f"Using context: {current_kubectl_context} for command: {command}"
351+
)
352+
353+
if current_namespace_context and ("--namespace" or "-n") not in command:
354+
command_parts = command.split()
355+
command_parts.insert(1, f"--namespace={current_namespace_context}")
356+
command = " ".join(command_parts)
357+
logger.info(
358+
f"Using context: {current_kubectl_context} and namespace: {current_namespace_context} for command: {command}"
359+
)
360+
361+
# Execute the full command as provided
362+
result = subprocess.run(
363+
command.split(), capture_output=True, text=True, check=True
364+
)
365+
return result.stdout
366+
except subprocess.CalledProcessError as e:
367+
logger.error(f"Error running kubectl command: {e.stderr}")
368+
return f"Error: {e.stderr}"
369+
370+
371+
# main entry point to run the MCP server
372+
async def main():
373+
# Use run_async() in async contexts
374+
await mcp.run_async(transport="http", host="127.0.0.1", port=8000)
375+
376+
377+
sc()
378+
sn()
379+
380+
main_app = mcp.http_app()
381+
382+
# if __name__ == "__main__":
383+
# logger.info("Starting ArgoKubernetes MCP.")
384+
# mcp.run()

0 commit comments

Comments
 (0)