mcp-cmd-sandbox 0.1.0__tar.gz
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.
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-cmd-sandbox"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
dependencies = ["fastmcp", "python-on-whales"]
|
|
6
|
+
|
|
7
|
+
[build-system]
|
|
8
|
+
requires = ["uv_build>=0.6.6"]
|
|
9
|
+
build-backend = "uv_build"
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
mcp-cmd-sandbox = "mcp_cmd_sandbox:main"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""MCP server for isolated container execution via Docker/Podman."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastmcp import Context, FastMCP
|
|
8
|
+
from fastmcp.dependencies import CurrentContext
|
|
9
|
+
from fastmcp.server.lifespan import Lifespan
|
|
10
|
+
from python_on_whales import DockerClient
|
|
11
|
+
|
|
12
|
+
_parser = argparse.ArgumentParser(prog="mcp-cmd-sandbox")
|
|
13
|
+
_ = _parser.add_argument(
|
|
14
|
+
"container_binary",
|
|
15
|
+
nargs="*",
|
|
16
|
+
default=["podman"],
|
|
17
|
+
help="e.g. 'podman' or '-- docker --remote'",
|
|
18
|
+
)
|
|
19
|
+
_args = _parser.parse_args()
|
|
20
|
+
|
|
21
|
+
_SERVER_ID = uuid.uuid4()
|
|
22
|
+
_IS_PODMAN = "podman" in _args.container_binary[0]
|
|
23
|
+
|
|
24
|
+
volumes: dict[uuid.UUID, str] = {}
|
|
25
|
+
client = DockerClient(client_call=_args.container_binary)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# This cleans up the anonymous volumes created during the runtime of the mcp server
|
|
29
|
+
@Lifespan
|
|
30
|
+
async def remove_sessions(_):
|
|
31
|
+
# startup
|
|
32
|
+
yield
|
|
33
|
+
|
|
34
|
+
# shutdown
|
|
35
|
+
for vol in volumes.values():
|
|
36
|
+
try:
|
|
37
|
+
client.volume.remove(vol)
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_session_volume(ctx: Context):
|
|
43
|
+
id = uuid.uuid5(_SERVER_ID, ctx.session_id)
|
|
44
|
+
volume_name = f"mcp-cmd-sandbox-persistant-{id}"
|
|
45
|
+
|
|
46
|
+
if id not in volumes.keys():
|
|
47
|
+
_ = client.volume.create(volume_name)
|
|
48
|
+
volumes[id] = volume_name
|
|
49
|
+
|
|
50
|
+
return volume_name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
mcp = FastMCP("mcp-cmd-sandbox", lifespan=remove_sessions)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_cmd(command: str, image: str, writable: bool, ctx: Context) -> str:
|
|
57
|
+
volume = get_session_volume(ctx)
|
|
58
|
+
# TODO: this is sketchy af.
|
|
59
|
+
# this should be the pwd of the agent tool, not the mcp server
|
|
60
|
+
cwd = str(Path.cwd())
|
|
61
|
+
|
|
62
|
+
with client.run(
|
|
63
|
+
image,
|
|
64
|
+
["sh", "-c", command],
|
|
65
|
+
volumes=[
|
|
66
|
+
(cwd, "/workspace", "rw" if writable else ("O" if _IS_PODMAN else "ro")),
|
|
67
|
+
(volume, "/persistent", "rw"),
|
|
68
|
+
],
|
|
69
|
+
workdir="/workspace",
|
|
70
|
+
detach=True,
|
|
71
|
+
) as container:
|
|
72
|
+
_ = client.wait(container)
|
|
73
|
+
return container.logs()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool(
|
|
77
|
+
description=f"""
|
|
78
|
+
Run a command in an isolated container.
|
|
79
|
+
|
|
80
|
+
- /workspace: your project directory ({"overlay mount, writes discarded on exit" if _IS_PODMAN else "read-only"})
|
|
81
|
+
- /persistent: writable volume that survives across calls within the same session_id
|
|
82
|
+
|
|
83
|
+
Pick an image appropriate for the task:
|
|
84
|
+
debian:latest (default), rust:latest, python:3.12-slim,
|
|
85
|
+
gcc:latest, node:22-alpine, golang:latest, maven:latest
|
|
86
|
+
|
|
87
|
+
Use /persistent for build caches or artifacts across multiple calls.
|
|
88
|
+
See the execute_writable call for a writable workspace.
|
|
89
|
+
"""
|
|
90
|
+
)
|
|
91
|
+
def execute(
|
|
92
|
+
command: str, image: str = "debian:latest", ctx: Context = CurrentContext()
|
|
93
|
+
) -> str:
|
|
94
|
+
return _run_cmd(command, image, writable=False, ctx=ctx)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool()
|
|
98
|
+
def execute_writable(
|
|
99
|
+
command: str, image: str = "debian:latest", ctx: Context = CurrentContext()
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Run a command with write access to /workspace in an isolated container.
|
|
102
|
+
|
|
103
|
+
Only use this when you need to modify files on the host (sed -i, patch, writing build output, etc.).
|
|
104
|
+
Prefer the read-only 'execute' tool unless modification is required.
|
|
105
|
+
|
|
106
|
+
Same options as the execute mcp call.
|
|
107
|
+
"""
|
|
108
|
+
return _run_cmd(command, image, writable=True, ctx=ctx)
|