mcp-modal 0.2.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.
mcp_modal/server.py ADDED
@@ -0,0 +1,1258 @@
1
+ """MCP server for managing Modal (modal.com) apps, containers, volumes, and secrets.
2
+
3
+ All tools shell out to the local `modal` CLI, so they use whatever Modal profile /
4
+ credentials are configured on the host (`~/.modal.toml`). Account-scoped operations
5
+ (apps, containers, volumes, secrets, profiles, environments) run the plain `modal`
6
+ binary; operations that build/deploy/run a local project (`deploy`, `run`) wrap the
7
+ command in `uv run --directory=<project>` so the project's own virtualenv is used.
8
+ """
9
+ import logging
10
+ import os
11
+ import re
12
+ import signal
13
+ from typing import Any, Optional, List, Dict
14
+ import subprocess
15
+ import json
16
+
17
+ from mcp.server.fastmcp import FastMCP
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ mcp = FastMCP("modal-deploy")
22
+
23
+ # Matches http(s) URLs in CLI output so we can surface deployment / web-endpoint links.
24
+ _URL_RE = re.compile(r"https?://[^\s'\"<>]+")
25
+ # Matches a `KEY=VALUE` secret pair (but not CLI flags like `--force`).
26
+ _KEYVALUE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
27
+
28
+
29
+ def _uv_prefixed(command: List[str], uv_directory: Optional[str]) -> List[str]:
30
+ """Prefix a command with `uv run --directory=<dir>` when a project dir is given.
31
+
32
+ Deploying/running a Modal app requires the app's own uv virtualenv, so those
33
+ commands must run through `uv`. Account-scoped commands pass uv_directory=None.
34
+ """
35
+ if uv_directory:
36
+ return ["uv", "run", f"--directory={uv_directory}"] + command
37
+ return command
38
+
39
+
40
+ def _add_env(command: List[str], env: Optional[str]) -> List[str]:
41
+ """Append `-e <env>` to target a specific Modal environment, if provided."""
42
+ if env:
43
+ command.extend(["-e", env])
44
+ return command
45
+
46
+
47
+ def extract_urls(*texts: Optional[str]) -> List[str]:
48
+ """Collect unique http(s) URLs from CLI output (deployment / web-endpoint links)."""
49
+ urls: List[str] = []
50
+ for text in texts:
51
+ if not text:
52
+ continue
53
+ for match in _URL_RE.findall(text):
54
+ cleaned = match.rstrip(").,")
55
+ if cleaned not in urls:
56
+ urls.append(cleaned)
57
+ return urls
58
+
59
+
60
+ def run_modal_command(command: List[str], uv_directory: Optional[str] = None) -> Dict[str, Any]:
61
+ """Run a Modal CLI command to completion and return the result."""
62
+ try:
63
+ command = _uv_prefixed(command, uv_directory)
64
+ logger.info(f"Running command: {' '.join(command)}")
65
+ result = subprocess.run(
66
+ command,
67
+ capture_output=True,
68
+ text=True,
69
+ check=True
70
+ )
71
+ return {
72
+ "success": True,
73
+ "stdout": result.stdout,
74
+ "stderr": result.stderr,
75
+ "command": ' '.join(command)
76
+ }
77
+ except subprocess.CalledProcessError as e:
78
+ return {
79
+ "success": False,
80
+ "error": str(e),
81
+ "stdout": e.stdout,
82
+ "stderr": e.stderr,
83
+ "command": ' '.join(command)
84
+ }
85
+
86
+
87
+ def run_modal_streaming_command(
88
+ command: List[str], timeout_seconds: int, uv_directory: Optional[str] = None
89
+ ) -> Dict[str, Any]:
90
+ """Run a Modal CLI command that may stream indefinitely (e.g. `modal app logs`, `modal serve`).
91
+
92
+ Captures whatever output is produced within `timeout_seconds`. If the command is
93
+ still running at the deadline (i.e. it was streaming), the whole process group is
94
+ terminated and the partial output is returned with timed_out=True.
95
+ """
96
+ full_command = _uv_prefixed(command, uv_directory)
97
+ proc = subprocess.Popen(
98
+ full_command,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.PIPE,
101
+ text=True,
102
+ # New session so `modal` (a possible grandchild under `uv run`) can be killed as a group.
103
+ start_new_session=True,
104
+ )
105
+ logger.info(f"Running streaming command (timeout={timeout_seconds}s): {' '.join(full_command)}")
106
+ timed_out = False
107
+ try:
108
+ stdout, stderr = proc.communicate(timeout=timeout_seconds)
109
+ except subprocess.TimeoutExpired:
110
+ timed_out = True
111
+ try:
112
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
113
+ except ProcessLookupError:
114
+ pass
115
+ try:
116
+ stdout, stderr = proc.communicate(timeout=5)
117
+ except subprocess.TimeoutExpired:
118
+ try:
119
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
120
+ except ProcessLookupError:
121
+ pass
122
+ stdout, stderr = proc.communicate()
123
+
124
+ return {
125
+ "stdout": stdout or "",
126
+ "stderr": stderr or "",
127
+ "returncode": proc.returncode,
128
+ "timed_out": timed_out,
129
+ "command": ' '.join(full_command),
130
+ }
131
+
132
+
133
+ def handle_json_response(result: Dict[str, Any], error_prefix: str) -> Dict[str, Any]:
134
+ """Parse JSON CLI output into a standardized success/error response."""
135
+ if not result["success"]:
136
+ response = {"success": False, "error": f"{error_prefix}: {result.get('error', 'Unknown error')}"}
137
+ if result.get("stdout"):
138
+ response["stdout"] = result["stdout"]
139
+ if result.get("stderr"):
140
+ response["stderr"] = result["stderr"]
141
+ return response
142
+
143
+ try:
144
+ data = json.loads(result["stdout"])
145
+ return {"success": True, "data": data}
146
+ except json.JSONDecodeError as e:
147
+ response = {"success": False, "error": f"Failed to parse JSON output: {str(e)}"}
148
+ if result.get("stdout"):
149
+ response["stdout"] = result["stdout"]
150
+ if result.get("stderr"):
151
+ response["stderr"] = result["stderr"]
152
+ return response
153
+
154
+
155
+ def standardize_result(
156
+ result: Dict[str, Any], success_message: str, error_prefix: str
157
+ ) -> Dict[str, Any]:
158
+ """Build a uniform response for non-JSON action commands (stop, create, rm, ...)."""
159
+ response: Dict[str, Any] = {"success": result["success"], "command": result["command"]}
160
+ if not result["success"]:
161
+ response["error"] = f"{error_prefix}: {result.get('error', 'Unknown error')}"
162
+ else:
163
+ response["message"] = success_message
164
+ if result.get("stdout"):
165
+ response["stdout"] = result["stdout"]
166
+ if result.get("stderr"):
167
+ response["stderr"] = result["stderr"]
168
+ return response
169
+
170
+
171
+ def grep_lines(
172
+ text: str,
173
+ pattern: str,
174
+ regex: bool,
175
+ case_sensitive: bool,
176
+ context_lines: int,
177
+ max_matches: int,
178
+ ) -> Any:
179
+ """Grep `text` line-by-line, returning (total_matches, blocks) or (None, error_message).
180
+
181
+ Each block is a chunk of log text covering one or more matches and `context_lines`
182
+ of surrounding context. Matched lines are prefixed with ">", context lines with " ",
183
+ and every line is given its 1-based line number — grep `-C` style. Overlapping or
184
+ adjacent match windows are merged into a single block to avoid repeating lines.
185
+ """
186
+ flags = 0 if case_sensitive else re.IGNORECASE
187
+ try:
188
+ compiled = re.compile(pattern if regex else re.escape(pattern), flags)
189
+ except re.error as e:
190
+ return None, f"Invalid regex pattern: {e}"
191
+
192
+ lines = text.splitlines()
193
+ match_indices = [i for i, line in enumerate(lines) if compiled.search(line)]
194
+ total = len(match_indices)
195
+ shown = match_indices[:max_matches]
196
+ matched = set(match_indices) # mark every real match, even inside another's window
197
+
198
+ # Merge each shown match's [i-ctx, i+ctx] window into non-overlapping intervals.
199
+ intervals: List[List[int]] = []
200
+ for i in shown:
201
+ lo = max(0, i - context_lines)
202
+ hi = min(len(lines) - 1, i + context_lines)
203
+ if intervals and lo <= intervals[-1][1] + 1:
204
+ intervals[-1][1] = max(intervals[-1][1], hi)
205
+ else:
206
+ intervals.append([lo, hi])
207
+
208
+ blocks: List[str] = []
209
+ for lo, hi in intervals:
210
+ block = [
211
+ f"{'>' if n in matched else ' '} {n + 1}: {lines[n]}"
212
+ for n in range(lo, hi + 1)
213
+ ]
214
+ blocks.append("\n".join(block))
215
+ return total, blocks
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Deploy & run (compute) — these wrap the command in the project's uv venv
220
+ # ---------------------------------------------------------------------------
221
+
222
+ @mcp.tool()
223
+ async def deploy_modal_app(
224
+ absolute_path_to_app: str,
225
+ env: Optional[str] = None,
226
+ name: Optional[str] = None,
227
+ tag: Optional[str] = None,
228
+ strategy: Optional[str] = None,
229
+ stream_logs: bool = False,
230
+ ) -> Dict[str, Any]:
231
+ """
232
+ Deploy a Modal application (`modal deploy`). Deployed web endpoints persist after
233
+ this call returns, so any URLs in the output are live, shareable links.
234
+
235
+ Args:
236
+ absolute_path_to_app: Absolute path to the Modal app file to deploy. Its
237
+ directory must use `uv` and have `modal` installed in its virtualenv.
238
+ env: Optional Modal environment to deploy into.
239
+ name: Optional deployment name (`--name`).
240
+ tag: Optional version tag for the deployment (`--tag`).
241
+ strategy: Optional rollout strategy: "rolling" or "recreate" (`--strategy`).
242
+ stream_logs: If True, stream logs from the app after deploy (`--stream-logs`).
243
+
244
+ Returns:
245
+ A dictionary with deployment results. `urls` lists any web-endpoint/dashboard
246
+ links found in the output.
247
+ """
248
+ uv_directory = os.path.dirname(absolute_path_to_app)
249
+ app_name = os.path.basename(absolute_path_to_app)
250
+ try:
251
+ command = ["modal", "deploy", app_name]
252
+ if name:
253
+ command.extend(["--name", name])
254
+ if tag:
255
+ command.extend(["--tag", tag])
256
+ if strategy:
257
+ command.extend(["--strategy", strategy])
258
+ if stream_logs:
259
+ command.append("--stream-logs")
260
+ _add_env(command, env)
261
+
262
+ result = run_modal_command(command, uv_directory)
263
+ urls = extract_urls(result.get("stdout"), result.get("stderr"))
264
+ if urls:
265
+ result["urls"] = urls
266
+ return result
267
+ except Exception as e:
268
+ logger.error(f"Failed to deploy Modal app: {e}")
269
+ raise
270
+
271
+
272
+ @mcp.tool()
273
+ async def run_modal_app(
274
+ absolute_path_to_app: str,
275
+ function_name: Optional[str] = None,
276
+ env: Optional[str] = None,
277
+ detach: bool = False,
278
+ timeout_seconds: int = 120,
279
+ ) -> Dict[str, Any]:
280
+ """
281
+ Run a Modal function or local entrypoint (`modal run`). Unlike deploy, this executes
282
+ the app once and streams its logs; use it to test a function on Modal compute.
283
+
284
+ Args:
285
+ absolute_path_to_app: Absolute path to the Modal app file. Its directory must
286
+ use `uv` and have `modal` installed in its virtualenv.
287
+ function_name: Optional function / entrypoint name, e.g. "main". When omitted,
288
+ Modal runs the single entrypoint/function if the module has exactly one.
289
+ env: Optional Modal environment to target.
290
+ detach: If True, keep the app running on Modal even if this process disconnects
291
+ (`--detach`). Useful for long jobs you don't want cut off at the timeout.
292
+ timeout_seconds: Max seconds to collect output before returning. Defaults to 120.
293
+
294
+ Returns:
295
+ A dictionary with collected output. `truncated` is True when the run was still
296
+ going at the timeout. `urls` lists any links found in the output.
297
+ """
298
+ uv_directory = os.path.dirname(absolute_path_to_app)
299
+ app_name = os.path.basename(absolute_path_to_app)
300
+ func_ref = f"{app_name}::{function_name}" if function_name else app_name
301
+ try:
302
+ command = ["modal", "run"]
303
+ if detach:
304
+ command.append("--detach")
305
+ command.append(func_ref)
306
+ _add_env(command, env)
307
+
308
+ result = run_modal_streaming_command(command, timeout_seconds, uv_directory)
309
+ failed = result["returncode"] not in (0, None) and not result["timed_out"]
310
+ if failed:
311
+ response = {
312
+ "success": False,
313
+ "error": f"Run failed for '{func_ref}' (exit {result['returncode']})",
314
+ "command": result["command"],
315
+ }
316
+ if result["stdout"]:
317
+ response["stdout"] = result["stdout"]
318
+ if result["stderr"]:
319
+ response["stderr"] = result["stderr"]
320
+ return response
321
+
322
+ response = {
323
+ "success": True,
324
+ "func_ref": func_ref,
325
+ "output": result["stdout"],
326
+ "truncated": result["timed_out"],
327
+ "command": result["command"],
328
+ }
329
+ urls = extract_urls(result["stdout"], result["stderr"])
330
+ if urls:
331
+ response["urls"] = urls
332
+ if result["timed_out"]:
333
+ response["message"] = (
334
+ f"Run still active after {timeout_seconds}s; returning a snapshot. "
335
+ "Increase timeout_seconds, or pass detach=True to keep it running on Modal."
336
+ )
337
+ if result["stderr"]:
338
+ response["stderr"] = result["stderr"]
339
+ return response
340
+ except Exception as e:
341
+ logger.error(f"Failed to run Modal app '{func_ref}': {e}")
342
+ raise
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # App lifecycle
347
+ # ---------------------------------------------------------------------------
348
+
349
+ @mcp.tool()
350
+ async def list_modal_apps(env: Optional[str] = None) -> Dict[str, Any]:
351
+ """
352
+ List Modal apps that are currently deployed/running or recently stopped.
353
+
354
+ Useful for discovering the app name or ID to pass to other app tools.
355
+
356
+ Args:
357
+ env: Optional Modal environment to target. If omitted, uses the profile's
358
+ default environment (or the MODAL_ENVIRONMENT variable).
359
+
360
+ Returns:
361
+ A dictionary containing the parsed JSON list of apps.
362
+ """
363
+ try:
364
+ command = ["modal", "app", "list", "--json"]
365
+ _add_env(command, env)
366
+ result = run_modal_command(command)
367
+ response = handle_json_response(result, "Failed to list apps")
368
+ if response["success"]:
369
+ return {"success": True, "apps": response["data"]}
370
+ return response
371
+ except Exception as e:
372
+ logger.error(f"Failed to list Modal apps: {e}")
373
+ raise
374
+
375
+
376
+ @mcp.tool()
377
+ async def get_modal_app_logs(
378
+ app_identifier: str,
379
+ timeout_seconds: int = 30,
380
+ env: Optional[str] = None,
381
+ since: Optional[str] = None,
382
+ until: Optional[str] = None,
383
+ tail: Optional[int] = None,
384
+ search: Optional[str] = None,
385
+ source: Optional[str] = None,
386
+ follow: bool = False,
387
+ ) -> Dict[str, Any]:
388
+ """
389
+ Fetch logs for a Modal app by name or app ID (`modal app logs`).
390
+
391
+ By default the CLI fetches recent entries and exits. Pass `follow=True` to live-stream
392
+ (collected for up to `timeout_seconds`, then cut off as a snapshot). Use list_modal_apps
393
+ to discover the app name/ID.
394
+
395
+ Args:
396
+ app_identifier: App name (e.g. "my-app") or app ID (e.g. "ap-123456").
397
+ timeout_seconds: Max seconds to collect logs before returning. Defaults to 30.
398
+ env: Optional Modal environment to target.
399
+ since: Start of time range — ISO 8601 datetime or relative time like "2h", "30m", "1d".
400
+ until: End of time range (same formats as `since`).
401
+ tail: Show only the last N log entries.
402
+ search: Only include log lines matching this search text.
403
+ source: Filter by source: "stdout", "stderr", or "system".
404
+ follow: If True, live-stream logs until the app stops or the timeout is reached.
405
+
406
+ Returns:
407
+ A dictionary with the collected logs. `truncated` is True when the stream was still
408
+ active at the timeout (i.e. logs are a partial snapshot).
409
+ """
410
+ try:
411
+ command = ["modal", "app", "logs", app_identifier]
412
+ if follow:
413
+ command.append("-f")
414
+ if since:
415
+ command.extend(["--since", since])
416
+ if until:
417
+ command.extend(["--until", until])
418
+ if tail is not None:
419
+ command.extend(["--tail", str(tail)])
420
+ if search:
421
+ command.extend(["--search", search])
422
+ if source:
423
+ command.extend(["--source", source])
424
+ _add_env(command, env)
425
+
426
+ result = run_modal_streaming_command(command, timeout_seconds)
427
+
428
+ # A non-zero, non-timeout exit means a genuine failure (unknown app, auth error).
429
+ # A SIGTERM/SIGKILL from our timeout produces a negative return code, which is
430
+ # expected when we cut off a live stream.
431
+ failed = result["returncode"] not in (0, None) and not result["timed_out"]
432
+ if failed:
433
+ response = {
434
+ "success": False,
435
+ "error": f"Failed to get logs for '{app_identifier}' (exit {result['returncode']})",
436
+ "command": result["command"],
437
+ }
438
+ if result["stdout"]:
439
+ response["stdout"] = result["stdout"]
440
+ if result["stderr"]:
441
+ response["stderr"] = result["stderr"]
442
+ return response
443
+
444
+ response = {
445
+ "success": True,
446
+ "app_identifier": app_identifier,
447
+ "logs": result["stdout"],
448
+ "truncated": result["timed_out"],
449
+ "command": result["command"],
450
+ }
451
+ if result["timed_out"]:
452
+ response["message"] = (
453
+ f"App is still active and streaming; returning a {timeout_seconds}s snapshot. "
454
+ "Increase timeout_seconds for more, or stop the app for the full log."
455
+ )
456
+ if result["stderr"]:
457
+ response["stderr"] = result["stderr"]
458
+ return response
459
+ except Exception as e:
460
+ logger.error(f"Failed to get logs for Modal app '{app_identifier}': {e}")
461
+ raise
462
+
463
+
464
+ @mcp.tool()
465
+ async def stop_modal_app(app_identifier: str, env: Optional[str] = None) -> Dict[str, Any]:
466
+ """
467
+ Permanently stop a Modal app and terminate its running containers (`modal app stop`).
468
+
469
+ Args:
470
+ app_identifier: App name (e.g. "my-app") or app ID (e.g. "ap-123456").
471
+ env: Optional Modal environment to target.
472
+
473
+ Returns:
474
+ A dictionary containing the result of the stop operation.
475
+ """
476
+ try:
477
+ # `-y` avoids the interactive confirmation prompt, which would hang with no TTY.
478
+ command = ["modal", "app", "stop", "-y", app_identifier]
479
+ _add_env(command, env)
480
+ result = run_modal_command(command)
481
+ return standardize_result(
482
+ result, f"Successfully stopped app {app_identifier}", "Failed to stop app"
483
+ )
484
+ except Exception as e:
485
+ logger.error(f"Failed to stop Modal app '{app_identifier}': {e}")
486
+ raise
487
+
488
+
489
+ @mcp.tool()
490
+ async def rollback_modal_app(
491
+ app_identifier: str, version: Optional[str] = None, env: Optional[str] = None
492
+ ) -> Dict[str, Any]:
493
+ """
494
+ Roll a Modal app back to a previous deployment version (`modal app rollback`).
495
+
496
+ Args:
497
+ app_identifier: App name or app ID.
498
+ version: Optional specific version to roll back to. If omitted, Modal rolls back
499
+ to the immediately preceding version. Use get_modal_app_history to list versions.
500
+ env: Optional Modal environment to target.
501
+
502
+ Returns:
503
+ A dictionary containing the result of the rollback operation.
504
+ """
505
+ try:
506
+ command = ["modal", "app", "rollback", app_identifier]
507
+ if version:
508
+ command.append(str(version))
509
+ _add_env(command, env)
510
+ result = run_modal_command(command)
511
+ return standardize_result(
512
+ result, f"Successfully rolled back app {app_identifier}", "Failed to roll back app"
513
+ )
514
+ except Exception as e:
515
+ logger.error(f"Failed to roll back Modal app '{app_identifier}': {e}")
516
+ raise
517
+
518
+
519
+ @mcp.tool()
520
+ async def get_modal_app_history(app_identifier: str, env: Optional[str] = None) -> Dict[str, Any]:
521
+ """
522
+ Show a Modal app's deployment history (`modal app history`).
523
+
524
+ Useful for finding a version to pass to rollback_modal_app.
525
+
526
+ Args:
527
+ app_identifier: App name or app ID.
528
+ env: Optional Modal environment to target.
529
+
530
+ Returns:
531
+ A dictionary containing the parsed JSON deployment history.
532
+ """
533
+ try:
534
+ command = ["modal", "app", "history", "--json", app_identifier]
535
+ _add_env(command, env)
536
+ result = run_modal_command(command)
537
+ response = handle_json_response(result, "Failed to get app history")
538
+ if response["success"]:
539
+ return {"success": True, "history": response["data"]}
540
+ return response
541
+ except Exception as e:
542
+ logger.error(f"Failed to get history for Modal app '{app_identifier}': {e}")
543
+ raise
544
+
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # Containers
548
+ # ---------------------------------------------------------------------------
549
+
550
+ @mcp.tool()
551
+ async def list_modal_containers(app_id: Optional[str] = None, env: Optional[str] = None) -> Dict[str, Any]:
552
+ """
553
+ List all Modal containers that are currently running (`modal container list`).
554
+
555
+ Args:
556
+ app_id: Optional app ID to only list containers for that app.
557
+ env: Optional Modal environment to target.
558
+
559
+ Returns:
560
+ A dictionary containing the parsed JSON list of containers (IDs like "ta-...").
561
+ """
562
+ try:
563
+ command = ["modal", "container", "list", "--json"]
564
+ if app_id:
565
+ command.extend(["--app-id", app_id])
566
+ _add_env(command, env)
567
+ result = run_modal_command(command)
568
+ response = handle_json_response(result, "Failed to list containers")
569
+ if response["success"]:
570
+ return {"success": True, "containers": response["data"]}
571
+ return response
572
+ except Exception as e:
573
+ logger.error(f"Failed to list Modal containers: {e}")
574
+ raise
575
+
576
+
577
+ @mcp.tool()
578
+ async def get_modal_container_logs(
579
+ container_id: str,
580
+ timeout_seconds: int = 30,
581
+ since: Optional[str] = None,
582
+ until: Optional[str] = None,
583
+ tail: Optional[int] = None,
584
+ search: Optional[str] = None,
585
+ source: Optional[str] = None,
586
+ follow: bool = False,
587
+ ) -> Dict[str, Any]:
588
+ """
589
+ Fetch or stream logs for a specific Modal container (`modal container logs`).
590
+
591
+ Args:
592
+ container_id: Container ID (e.g. "ta-123456"), from list_modal_containers.
593
+ timeout_seconds: Max seconds to collect logs before returning. Defaults to 30.
594
+ since: Start of time range — ISO 8601 or relative like "2h", "30m", "1d".
595
+ until: End of time range (same formats as `since`).
596
+ tail: Show only the last N log entries.
597
+ search: Only include log lines matching this search text.
598
+ source: Filter by source: "stdout", "stderr", or "system".
599
+ follow: If True, live-stream logs until the container stops or the timeout hits.
600
+
601
+ Returns:
602
+ A dictionary with the collected logs. `truncated` is True when the stream was cut
603
+ off at the timeout.
604
+ """
605
+ try:
606
+ command = ["modal", "container", "logs", container_id]
607
+ if follow:
608
+ command.append("-f")
609
+ if since:
610
+ command.extend(["--since", since])
611
+ if until:
612
+ command.extend(["--until", until])
613
+ if tail is not None:
614
+ command.extend(["--tail", str(tail)])
615
+ if search:
616
+ command.extend(["--search", search])
617
+ if source:
618
+ command.extend(["--source", source])
619
+
620
+ result = run_modal_streaming_command(command, timeout_seconds)
621
+ failed = result["returncode"] not in (0, None) and not result["timed_out"]
622
+ if failed:
623
+ response = {
624
+ "success": False,
625
+ "error": f"Failed to get logs for container '{container_id}' (exit {result['returncode']})",
626
+ "command": result["command"],
627
+ }
628
+ if result["stdout"]:
629
+ response["stdout"] = result["stdout"]
630
+ if result["stderr"]:
631
+ response["stderr"] = result["stderr"]
632
+ return response
633
+
634
+ response = {
635
+ "success": True,
636
+ "container_id": container_id,
637
+ "logs": result["stdout"],
638
+ "truncated": result["timed_out"],
639
+ "command": result["command"],
640
+ }
641
+ if result["timed_out"]:
642
+ response["message"] = (
643
+ f"Container is still active and streaming; returning a {timeout_seconds}s snapshot."
644
+ )
645
+ if result["stderr"]:
646
+ response["stderr"] = result["stderr"]
647
+ return response
648
+ except Exception as e:
649
+ logger.error(f"Failed to get logs for Modal container '{container_id}': {e}")
650
+ raise
651
+
652
+
653
+ @mcp.tool()
654
+ async def exec_modal_container(
655
+ container_id: str, command: List[str], timeout_seconds: int = 60
656
+ ) -> Dict[str, Any]:
657
+ """
658
+ Execute a command inside a running Modal container (`modal container exec`).
659
+
660
+ Args:
661
+ container_id: Container ID (e.g. "ta-123456"), from list_modal_containers.
662
+ command: The command to run as a list of arguments,
663
+ e.g. ["python", "-c", "print('hi')"] or ["ls", "-la", "/"].
664
+ timeout_seconds: Max seconds to wait for the command before returning. Defaults to 60.
665
+
666
+ Returns:
667
+ A dictionary with the command's captured output. `truncated` is True if the
668
+ command was still running at the timeout.
669
+ """
670
+ if not command:
671
+ return {"success": False, "error": "A non-empty command list is required"}
672
+ try:
673
+ # `--no-pty` avoids allocating a PTY, which isn't available in this subprocess.
674
+ full_command = ["modal", "container", "exec", "--no-pty", container_id] + command
675
+ result = run_modal_streaming_command(full_command, timeout_seconds)
676
+ failed = result["returncode"] not in (0, None) and not result["timed_out"]
677
+ response = {
678
+ "success": not failed,
679
+ "container_id": container_id,
680
+ "output": result["stdout"],
681
+ "returncode": result["returncode"],
682
+ "truncated": result["timed_out"],
683
+ "command": result["command"],
684
+ }
685
+ if failed:
686
+ response["error"] = f"Command exited with code {result['returncode']}"
687
+ if result["stderr"]:
688
+ response["stderr"] = result["stderr"]
689
+ return response
690
+ except Exception as e:
691
+ logger.error(f"Failed to exec in Modal container '{container_id}': {e}")
692
+ raise
693
+
694
+
695
+ @mcp.tool()
696
+ async def stop_modal_container(container_id: str) -> Dict[str, Any]:
697
+ """
698
+ Terminate a running Modal container (`modal container stop`).
699
+
700
+ Sends SIGINT to the container; in-flight inputs are cancelled and rescheduled elsewhere.
701
+
702
+ Args:
703
+ container_id: Container ID (e.g. "ta-123456"), from list_modal_containers.
704
+
705
+ Returns:
706
+ A dictionary containing the result of the stop operation.
707
+ """
708
+ try:
709
+ # `-y` avoids the interactive confirmation prompt.
710
+ result = run_modal_command(["modal", "container", "stop", "-y", container_id])
711
+ return standardize_result(
712
+ result, f"Successfully stopped container {container_id}", "Failed to stop container"
713
+ )
714
+ except Exception as e:
715
+ logger.error(f"Failed to stop Modal container '{container_id}': {e}")
716
+ raise
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # Log search (apps & containers)
721
+ # ---------------------------------------------------------------------------
722
+
723
+ @mcp.tool()
724
+ async def search_modal_logs(
725
+ identifier: str,
726
+ pattern: str,
727
+ target: str = "app",
728
+ regex: bool = False,
729
+ case_sensitive: bool = False,
730
+ context_lines: int = 3,
731
+ max_matches: int = 50,
732
+ since: Optional[str] = None,
733
+ tail: Optional[int] = None,
734
+ timeout_seconds: int = 30,
735
+ env: Optional[str] = None,
736
+ ) -> Dict[str, Any]:
737
+ """
738
+ Search an app's or container's logs for a pattern and return matches WITH surrounding
739
+ context — useful for finding where something went wrong (errors, tracebacks, a request
740
+ ID, etc.). Logs are fetched once and grepped locally, so unlike the `search` argument
741
+ on the log tools you get the lines around each hit, not just the matching line.
742
+
743
+ Args:
744
+ identifier: App name/ID (e.g. "my-app", "ap-123456") or container ID ("ta-123456").
745
+ pattern: Text (or regex, if `regex=True`) to search for, e.g. "Traceback", "Error",
746
+ "timeout", or a request/job ID.
747
+ target: What `identifier` refers to: "app" (default) or "container".
748
+ regex: If True, treat `pattern` as a Python regular expression instead of literal text.
749
+ case_sensitive: If True, match case-sensitively. Defaults to case-insensitive.
750
+ context_lines: Number of lines to include before and after each match. Defaults to 3.
751
+ max_matches: Cap on the number of match blocks returned. Defaults to 50.
752
+ since: Only search logs newer than this — ISO 8601 or relative like "2h", "1d".
753
+ tail: Only search the last N log entries. If neither `since` nor `tail` is given,
754
+ the last 1000 entries are searched.
755
+ timeout_seconds: Max seconds to spend fetching logs before searching. Defaults to 30.
756
+ env: Optional Modal environment (apps only).
757
+
758
+ Returns:
759
+ A dictionary with `match_count` (total hits), `matches` (a list of context blocks,
760
+ each a string with line numbers; matched lines are prefixed with ">"), and
761
+ `returned` (how many blocks are included after `max_matches`).
762
+ """
763
+ if target not in ("app", "container"):
764
+ return {"success": False, "error": "target must be 'app' or 'container'"}
765
+ if not pattern:
766
+ return {"success": False, "error": "A non-empty search pattern is required"}
767
+ try:
768
+ subcommand = "app" if target == "app" else "container"
769
+ command = ["modal", subcommand, "logs", identifier]
770
+ if since:
771
+ command.extend(["--since", since])
772
+ if tail is not None:
773
+ command.extend(["--tail", str(tail)])
774
+ if since is None and tail is None:
775
+ # Search a generous window by default so debugging isn't limited to ~100 lines.
776
+ command.extend(["--tail", "1000"])
777
+ if target == "app":
778
+ _add_env(command, env)
779
+
780
+ result = run_modal_streaming_command(command, timeout_seconds)
781
+ failed = result["returncode"] not in (0, None) and not result["timed_out"]
782
+ if failed:
783
+ response = {
784
+ "success": False,
785
+ "error": f"Failed to fetch logs for '{identifier}' (exit {result['returncode']})",
786
+ "command": result["command"],
787
+ }
788
+ if result["stderr"]:
789
+ response["stderr"] = result["stderr"]
790
+ return response
791
+
792
+ # Modal writes log lines to stdout; some builds emit them on stderr — search both.
793
+ log_text = result["stdout"] or result["stderr"] or ""
794
+ total, blocks = grep_lines(
795
+ log_text, pattern, regex, case_sensitive, context_lines, max_matches
796
+ )
797
+ if total is None:
798
+ # grep_lines returned an error message (e.g. bad regex) in `blocks`.
799
+ return {"success": False, "error": blocks, "command": result["command"]}
800
+
801
+ response = {
802
+ "success": True,
803
+ "target": target,
804
+ "identifier": identifier,
805
+ "pattern": pattern,
806
+ "match_count": total,
807
+ "returned": len(blocks),
808
+ "matches": blocks,
809
+ "logs_truncated": result["timed_out"],
810
+ "command": result["command"],
811
+ }
812
+ if total == 0:
813
+ response["message"] = (
814
+ f"No matches for {pattern!r} in the fetched logs. Try a broader pattern, "
815
+ "increase `tail`/`since`, or set regex=True."
816
+ )
817
+ elif len(blocks) < total:
818
+ response["message"] = (
819
+ f"Showing the first {len(blocks)} of {total} matches; increase max_matches for more."
820
+ )
821
+ if result["timed_out"]:
822
+ response.setdefault("message", "")
823
+ response["message"] = (
824
+ (response["message"] + " ").lstrip()
825
+ + f"Log fetch was cut off at {timeout_seconds}s, so older entries may be missing."
826
+ )
827
+ return response
828
+ except Exception as e:
829
+ logger.error(f"Failed to search logs for '{identifier}': {e}")
830
+ raise
831
+
832
+
833
+ # ---------------------------------------------------------------------------
834
+ # Volumes — file operations
835
+ # ---------------------------------------------------------------------------
836
+
837
+ @mcp.tool()
838
+ async def list_modal_volumes() -> Dict[str, Any]:
839
+ """
840
+ List all Modal volumes using the Modal CLI with JSON output.
841
+
842
+ Returns:
843
+ A dictionary containing the parsed JSON output of the Modal volumes list.
844
+ """
845
+ try:
846
+ result = run_modal_command(["modal", "volume", "list", "--json"])
847
+ response = handle_json_response(result, "Failed to list volumes")
848
+ if response["success"]:
849
+ return {"success": True, "volumes": response["data"]}
850
+ return response
851
+ except Exception as e:
852
+ logger.error(f"Failed to list Modal volumes: {e}")
853
+ raise
854
+
855
+
856
+ @mcp.tool()
857
+ async def list_modal_volume_contents(volume_name: str, path: str = "/") -> Dict[str, Any]:
858
+ """
859
+ List files and directories in a Modal volume.
860
+
861
+ Args:
862
+ volume_name: Name of the Modal volume to list contents from.
863
+ path: Path within the volume to list contents from. Defaults to root ("/").
864
+
865
+ Returns:
866
+ A dictionary containing the parsed JSON output of the volume contents.
867
+ """
868
+ try:
869
+ result = run_modal_command(["modal", "volume", "ls", "--json", volume_name, path])
870
+ response = handle_json_response(result, "Failed to list volume contents")
871
+ if response["success"]:
872
+ return {"success": True, "contents": response["data"]}
873
+ return response
874
+ except Exception as e:
875
+ logger.error(f"Failed to list Modal volume contents: {e}")
876
+ raise
877
+
878
+
879
+ @mcp.tool()
880
+ async def copy_modal_volume_files(volume_name: str, paths: List[str]) -> Dict[str, Any]:
881
+ """
882
+ Copy files within a Modal volume. Can copy a source file to a destination file
883
+ or multiple source files to a destination directory.
884
+
885
+ Args:
886
+ volume_name: Name of the Modal volume to perform copy operation in.
887
+ paths: List of paths for the copy operation. The last path is the destination,
888
+ all others are sources. For example: ["source1.txt", "source2.txt", "dest_dir/"]
889
+
890
+ Returns:
891
+ A dictionary containing the result of the copy operation.
892
+ """
893
+ if len(paths) < 2:
894
+ return {
895
+ "success": False,
896
+ "error": "At least one source and one destination path are required"
897
+ }
898
+
899
+ try:
900
+ result = run_modal_command(["modal", "volume", "cp", volume_name] + paths)
901
+ return standardize_result(
902
+ result, f"Successfully copied files in volume {volume_name}", "Failed to copy files"
903
+ )
904
+ except Exception as e:
905
+ logger.error(f"Failed to copy files in Modal volume: {e}")
906
+ raise
907
+
908
+
909
+ @mcp.tool()
910
+ async def remove_modal_volume_file(volume_name: str, remote_path: str, recursive: bool = False) -> Dict[str, Any]:
911
+ """
912
+ Delete a file or directory from a Modal volume.
913
+
914
+ Args:
915
+ volume_name: Name of the Modal volume to delete from.
916
+ remote_path: Path to the file or directory to delete.
917
+ recursive: If True, delete directories recursively. Required for deleting directories.
918
+
919
+ Returns:
920
+ A dictionary containing the result of the delete operation.
921
+ """
922
+ try:
923
+ command = ["modal", "volume", "rm"]
924
+ if recursive:
925
+ command.append("-r")
926
+ command.extend([volume_name, remote_path])
927
+
928
+ result = run_modal_command(command)
929
+ return standardize_result(
930
+ result,
931
+ f"Successfully deleted {remote_path} from volume {volume_name}",
932
+ f"Failed to delete {remote_path}",
933
+ )
934
+ except Exception as e:
935
+ logger.error(f"Failed to delete from Modal volume: {e}")
936
+ raise
937
+
938
+
939
+ @mcp.tool()
940
+ async def put_modal_volume_file(volume_name: str, local_path: str, remote_path: str = "/", force: bool = False) -> Dict[str, Any]:
941
+ """
942
+ Upload a file or directory to a Modal volume.
943
+
944
+ Args:
945
+ volume_name: Name of the Modal volume to upload to.
946
+ local_path: Path to the local file or directory to upload.
947
+ remote_path: Path in the volume to upload to. Defaults to root ("/").
948
+ If ending with "/", it's treated as a directory and the file keeps its name.
949
+ force: If True, overwrite existing files. Defaults to False.
950
+
951
+ Returns:
952
+ A dictionary containing the result of the upload operation.
953
+ """
954
+ try:
955
+ command = ["modal", "volume", "put"]
956
+ if force:
957
+ command.append("-f")
958
+ command.extend([volume_name, local_path, remote_path])
959
+
960
+ result = run_modal_command(command)
961
+ return standardize_result(
962
+ result,
963
+ f"Successfully uploaded {local_path} to {volume_name}:{remote_path}",
964
+ f"Failed to upload {local_path}",
965
+ )
966
+ except Exception as e:
967
+ logger.error(f"Failed to upload to Modal volume: {e}")
968
+ raise
969
+
970
+
971
+ @mcp.tool()
972
+ async def get_modal_volume_file(volume_name: str, remote_path: str, local_destination: str = ".", force: bool = False) -> Dict[str, Any]:
973
+ """
974
+ Download files from a Modal volume.
975
+
976
+ Args:
977
+ volume_name: Name of the Modal volume to download from.
978
+ remote_path: Path to the file or directory in the volume to download.
979
+ local_destination: Local path to save the downloaded file(s). Defaults to current directory.
980
+ Use "-" to write file contents to stdout.
981
+ force: If True, overwrite existing files. Defaults to False.
982
+
983
+ Returns:
984
+ A dictionary containing the result of the download operation.
985
+ """
986
+ try:
987
+ command = ["modal", "volume", "get"]
988
+ if force:
989
+ command.append("--force")
990
+ command.extend([volume_name, remote_path, local_destination])
991
+
992
+ result = run_modal_command(command)
993
+ return standardize_result(
994
+ result,
995
+ f"Successfully downloaded {remote_path} from volume {volume_name}",
996
+ f"Failed to download {remote_path}",
997
+ )
998
+ except Exception as e:
999
+ logger.error(f"Failed to download from Modal volume: {e}")
1000
+ raise
1001
+
1002
+
1003
+ # ---------------------------------------------------------------------------
1004
+ # Volumes — lifecycle
1005
+ # ---------------------------------------------------------------------------
1006
+
1007
+ @mcp.tool()
1008
+ async def create_modal_volume(volume_name: str, env: Optional[str] = None) -> Dict[str, Any]:
1009
+ """
1010
+ Create a named, persistent Modal volume (`modal volume create`).
1011
+
1012
+ Args:
1013
+ volume_name: Name for the new volume.
1014
+ env: Optional Modal environment to create the volume in.
1015
+
1016
+ Returns:
1017
+ A dictionary containing the result of the create operation.
1018
+ """
1019
+ try:
1020
+ command = ["modal", "volume", "create", volume_name]
1021
+ _add_env(command, env)
1022
+ result = run_modal_command(command)
1023
+ return standardize_result(
1024
+ result, f"Successfully created volume {volume_name}", "Failed to create volume"
1025
+ )
1026
+ except Exception as e:
1027
+ logger.error(f"Failed to create Modal volume '{volume_name}': {e}")
1028
+ raise
1029
+
1030
+
1031
+ @mcp.tool()
1032
+ async def delete_modal_volume(volume_name: str, env: Optional[str] = None) -> Dict[str, Any]:
1033
+ """
1034
+ Delete a named Modal volume and ALL of its data (`modal volume delete`).
1035
+
1036
+ This is irreversible — the entire volume and its contents are removed. To delete
1037
+ individual files instead, use remove_modal_volume_file.
1038
+
1039
+ Args:
1040
+ volume_name: Name of the volume to delete.
1041
+ env: Optional Modal environment to target.
1042
+
1043
+ Returns:
1044
+ A dictionary containing the result of the delete operation.
1045
+ """
1046
+ try:
1047
+ # `-y` avoids the interactive confirmation prompt.
1048
+ command = ["modal", "volume", "delete", "-y", volume_name]
1049
+ _add_env(command, env)
1050
+ result = run_modal_command(command)
1051
+ return standardize_result(
1052
+ result, f"Successfully deleted volume {volume_name}", "Failed to delete volume"
1053
+ )
1054
+ except Exception as e:
1055
+ logger.error(f"Failed to delete Modal volume '{volume_name}': {e}")
1056
+ raise
1057
+
1058
+
1059
+ @mcp.tool()
1060
+ async def rename_modal_volume(old_name: str, new_name: str, env: Optional[str] = None) -> Dict[str, Any]:
1061
+ """
1062
+ Rename a Modal volume (`modal volume rename`).
1063
+
1064
+ Args:
1065
+ old_name: Current volume name.
1066
+ new_name: New volume name.
1067
+ env: Optional Modal environment to target.
1068
+
1069
+ Returns:
1070
+ A dictionary containing the result of the rename operation.
1071
+ """
1072
+ try:
1073
+ # `-y` avoids the interactive confirmation prompt.
1074
+ command = ["modal", "volume", "rename", "-y", old_name, new_name]
1075
+ _add_env(command, env)
1076
+ result = run_modal_command(command)
1077
+ return standardize_result(
1078
+ result, f"Successfully renamed volume {old_name} to {new_name}", "Failed to rename volume"
1079
+ )
1080
+ except Exception as e:
1081
+ logger.error(f"Failed to rename Modal volume '{old_name}': {e}")
1082
+ raise
1083
+
1084
+
1085
+ # ---------------------------------------------------------------------------
1086
+ # Secrets
1087
+ # ---------------------------------------------------------------------------
1088
+
1089
+ @mcp.tool()
1090
+ async def list_modal_secrets(env: Optional[str] = None) -> Dict[str, Any]:
1091
+ """
1092
+ List your published Modal secrets (`modal secret list`). Only names and timestamps
1093
+ are returned — secret values are never exposed by the CLI.
1094
+
1095
+ Args:
1096
+ env: Optional Modal environment to target.
1097
+
1098
+ Returns:
1099
+ A dictionary containing the parsed JSON list of secrets.
1100
+ """
1101
+ try:
1102
+ command = ["modal", "secret", "list", "--json"]
1103
+ _add_env(command, env)
1104
+ result = run_modal_command(command)
1105
+ response = handle_json_response(result, "Failed to list secrets")
1106
+ if response["success"]:
1107
+ return {"success": True, "secrets": response["data"]}
1108
+ return response
1109
+ except Exception as e:
1110
+ logger.error(f"Failed to list Modal secrets: {e}")
1111
+ raise
1112
+
1113
+
1114
+ @mcp.tool()
1115
+ async def create_modal_secret(
1116
+ secret_name: str,
1117
+ key_values: Optional[Dict[str, str]] = None,
1118
+ from_dotenv: Optional[str] = None,
1119
+ from_json: Optional[str] = None,
1120
+ force: bool = False,
1121
+ env: Optional[str] = None,
1122
+ ) -> Dict[str, Any]:
1123
+ """
1124
+ Create a Modal secret (`modal secret create`). Provide the key/value pairs inline,
1125
+ or load them from a local .env or JSON file. Secret values are redacted from the
1126
+ returned `command` field.
1127
+
1128
+ Args:
1129
+ secret_name: Name for the secret.
1130
+ key_values: Mapping of secret keys to values, e.g. {"API_KEY": "abc", "DB_URL": "..."}.
1131
+ from_dotenv: Path to a local .env file to load key/values from (`--from-dotenv`).
1132
+ from_json: Path to a local JSON file to load key/values from (`--from-json`).
1133
+ force: If True, overwrite the secret if it already exists (`--force`).
1134
+ env: Optional Modal environment to create the secret in.
1135
+
1136
+ Returns:
1137
+ A dictionary containing the result of the create operation, with values redacted.
1138
+ """
1139
+ if not key_values and not from_dotenv and not from_json:
1140
+ return {
1141
+ "success": False,
1142
+ "error": "Provide key_values, from_dotenv, or from_json to create a secret",
1143
+ }
1144
+ try:
1145
+ command = ["modal", "secret", "create", secret_name]
1146
+ if key_values:
1147
+ command.extend([f"{k}={v}" for k, v in key_values.items()])
1148
+ if from_dotenv:
1149
+ command.extend(["--from-dotenv", from_dotenv])
1150
+ if from_json:
1151
+ command.extend(["--from-json", from_json])
1152
+ if force:
1153
+ command.append("--force")
1154
+ _add_env(command, env)
1155
+
1156
+ result = run_modal_command(command)
1157
+ # Redact KEY=VALUE pairs so secret values never appear in the returned command.
1158
+ result["command"] = ' '.join(
1159
+ re.sub(r"=.*", "=***", part) if _KEYVALUE_RE.match(part) else part
1160
+ for part in result["command"].split(' ')
1161
+ )
1162
+ return standardize_result(
1163
+ result, f"Successfully created secret {secret_name}", "Failed to create secret"
1164
+ )
1165
+ except Exception as e:
1166
+ logger.error(f"Failed to create Modal secret '{secret_name}': {e}")
1167
+ raise
1168
+
1169
+
1170
+ @mcp.tool()
1171
+ async def delete_modal_secret(secret_name: str, env: Optional[str] = None) -> Dict[str, Any]:
1172
+ """
1173
+ Delete a named Modal secret (`modal secret delete`).
1174
+
1175
+ Args:
1176
+ secret_name: Name of the secret to delete.
1177
+ env: Optional Modal environment to target.
1178
+
1179
+ Returns:
1180
+ A dictionary containing the result of the delete operation.
1181
+ """
1182
+ try:
1183
+ # `-y` avoids the interactive confirmation prompt.
1184
+ command = ["modal", "secret", "delete", "-y", secret_name]
1185
+ _add_env(command, env)
1186
+ result = run_modal_command(command)
1187
+ return standardize_result(
1188
+ result, f"Successfully deleted secret {secret_name}", "Failed to delete secret"
1189
+ )
1190
+ except Exception as e:
1191
+ logger.error(f"Failed to delete Modal secret '{secret_name}': {e}")
1192
+ raise
1193
+
1194
+
1195
+ # ---------------------------------------------------------------------------
1196
+ # Discovery — who am I, what environments exist
1197
+ # ---------------------------------------------------------------------------
1198
+
1199
+ @mcp.tool()
1200
+ async def get_modal_profile() -> Dict[str, Any]:
1201
+ """
1202
+ Show the active Modal profile and all configured profiles (`modal profile current`
1203
+ + `modal profile list`). Use this to confirm which workspace/account the server is
1204
+ authenticated as before running account-scoped operations.
1205
+
1206
+ Returns:
1207
+ A dictionary with the active profile name and the parsed JSON list of profiles.
1208
+ """
1209
+ try:
1210
+ current = run_modal_command(["modal", "profile", "current"])
1211
+ listing = run_modal_command(["modal", "profile", "list", "--json"])
1212
+
1213
+ response: Dict[str, Any] = {"success": current["success"] and listing["success"]}
1214
+ if current["success"]:
1215
+ response["active_profile"] = current["stdout"].strip()
1216
+ profiles = handle_json_response(listing, "Failed to list profiles")
1217
+ if profiles["success"]:
1218
+ response["profiles"] = profiles["data"]
1219
+ elif "error" not in response:
1220
+ response["error"] = profiles.get("error")
1221
+ if not response["success"] and "error" not in response:
1222
+ response["error"] = current.get("error") or listing.get("error")
1223
+ return response
1224
+ except Exception as e:
1225
+ logger.error(f"Failed to get Modal profile: {e}")
1226
+ raise
1227
+
1228
+
1229
+ @mcp.tool()
1230
+ async def list_modal_environments() -> Dict[str, Any]:
1231
+ """
1232
+ List all environments in the current Modal workspace (`modal environment list`).
1233
+
1234
+ Environments are sub-divisions of a workspace (e.g. "dev" vs "production"), each with
1235
+ its own apps and secrets. The names returned here are valid `env` arguments for the
1236
+ other tools.
1237
+
1238
+ Returns:
1239
+ A dictionary containing the parsed JSON list of environments.
1240
+ """
1241
+ try:
1242
+ result = run_modal_command(["modal", "environment", "list", "--json"])
1243
+ response = handle_json_response(result, "Failed to list environments")
1244
+ if response["success"]:
1245
+ return {"success": True, "environments": response["data"]}
1246
+ return response
1247
+ except Exception as e:
1248
+ logger.error(f"Failed to list Modal environments: {e}")
1249
+ raise
1250
+
1251
+
1252
+ def main() -> None:
1253
+ """Console-script entry point for the mcp-modal package."""
1254
+ mcp.run()
1255
+
1256
+
1257
+ if __name__ == "__main__":
1258
+ main()