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()
|